Commit Graph

221 Commits

Author SHA1 Message Date
martbost 1555d4ef6f Announcement card: a notice can explain itself
The cancellation card could only say two lines, an eyebrow and a date label,
because the card has no prose field and the API never passed one. A notice needs
to say what happened and what comes next, so it now carries a body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:46:25 -05:00
martbost 4c6fb7bed2 A page per day: the whole contract, midnight to midnight
The daily snapshot outgrew Telegram. Past 1,000 members the post hit the
4,096-character limit and was rejected outright two mornings running, and
capping the lists to fit meant choosing what to throw away. Manson caught what
that cost: the level-up list was ordered by time, so the cap hid two Culmen and
thirteen Fabrica behind two dozen routine Ascensus upgrades.

/day/<date> now carries every climb, every payment with its Polygonscan link,
every new position and every recruiter, with no cap on any of it. Company-wide,
no team filter, keyed to the Central calendar day so a link posted on Monday
still shows Monday next year. Today is computed live; a finished day is written
to disk once, because the chain index only keeps a few hundred payouts and an
older day could never be rebuilt from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 05:41:31 -05:00
martbost 2c6b116e9a Add to Calendar: hand phones the .ics file instead of a Google link
On a phone the Google Calendar app claims calendar.google.com links and opens
its own home screen, dropping the event. The pop-up now offers one "Add to
Calendar" button that goes to the calendar file on iOS/Android (served inline
so the phone hands it to its calendar) and to the Google event editor on
desktop, with the other route as the second button either way. The .ics is
also folded to RFC 5545 line lengths so Outlook accepts it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:26:21 -05:00
martbost 9249ae6368 Member milestones: a celebration photo + caption to the proof channel, team feed and shared payments topic when the contract reaches 1,000 members (then 2,500 / 5,000 / 10,000), once per milestone; sendPhoto capability; admin preview route
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:50:56 -05:00
martbost d72248bacb SafePal is the preferred wallet: listed first with a Recommended tag in the picker, detected by flag/window.safepal, named first in the in-app notice, join error, chatbot answer and AI prompt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 08:45:14 -05:00
martbost f28804862b Training: two NAS zones (above the first video, above the Circle Method); CSP allows the NAS script and https images
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 16:41:25 -05:00
martbost c6e6acbdc5 PolHunter embed on /training, CSP allows polhunter.com
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:12:25 -05:00
martbost 9ad864c0ba join-click and admin: finish removing the queue calls (an idempotency guard skipped two edits)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:30:58 -05:00
martbost 7c8a43a055 Delete the rotation queue; sponsors.json stays as the member directory
Marty (2026-09-19): delete it if it no longer serves a purpose. The public link has run on the
chain walk since the mode flip and the queue's active head never received a join. Gone:
active/waiting/qualified statuses, normalizeStatuses, activeSponsor, publicSponsorPayload, the
increment/activate/qualify/reset actions, the click counter, the auto-count/auto-advance/
level-sync block, the legacy queue branches in current-sponsor and the submit-id classifier,
the chatbot's "placements waiting" facts, and the queue columns and buttons in admin. What stays
is a member directory (name, parent, level, notes, contact email) because the payout alerts and
the feed's name map read it. The chain walk is the only company rotation now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:26:25 -05:00
martbost 4981b1a923 Retire the rotation queue's voice; team links pick the nearest open spot in their own leg
The queue's AUTO-ADVANCE post announced a rotation event this morning when a member had
built his own leg (the Triple Play). The queue stopped placing anyone when the public link
moved to the chain walk; what was left of it was three Telegram messages, a classifier that
called any join under a former queue member a "rotation join" and swept the newcomer into
the queue, and a preference inside the team-link picker for queue positions over the nearest
open one (15 members affected).

Verified offline over the full snapshot first: all 889 personal links already target the
member or a position inside their own subtree; the walk starts at the member's own children
and cannot leave the leg. Now: the three messages are gone (counters still update silently);
a join is a rotation join only if the company link could have produced it (the company link
always offers the shallowest open position, so a deeper referrer rules it out; 'invite-<id>'
submissions are leg joins outright); nothing is auto-added to the queue; and the team link
picks the nearest open position in the member's own leg, in tree order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 06:50:32 -05:00
martbost 07ab4cec79 Members choose how their link places people
Terry asked how a referral of his would be set up as "Direct with
spillover", and whether his team could follow suit. Two settings, on the
member's own dashboard:

  Team rotation — the join goes to the next position IN THEIR OWN TEAM
                  that still needs its 2
  Direct        — every join lands directly under them, as depth

A new position starts on whatever its SPONSOR was using at the moment it
joined, so a team duplicates its leader by default. That is a one-time
copy, not a live link: a sponsor changing their mind later never moves
anyone already in their team, and an explicit choice is never overwritten
from above. The panel says where an inherited setting came from, so nobody
discovers months later that their link was doing something they did not
pick.

Also closed the hole behind Terry's original complaint: a member's
personal link no longer falls through to the COMPANY rotation when their
leg is fully qualified. Team rotation means rotation inside their own
downline and nothing else — handing someone's referral to a position they
have never met was never the intent, and it is what offered Terry's
prospects #148.

The five positions already on directDefaultIds (21, 137, 139, 30, 840)
read as explicit Direct, so nothing changes for them. Everyone else stays
on Team rotation, which is what they have today.

qa/placement.mjs, 17 checks: defaults, choosing, inheritance from the
sponsor, explicit choices surviving a re-run, a sponsor switching not
moving their existing team, and persistence across a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:54:14 -05:00
martbost 5c34ab3305 Remove the duplicate-hunt instrumentation, keep the audit line
The verbose per-send logging did its job: it proved this process sends
each payout once, which is what pointed the search away from the deployed
app and at a second server running elsewhere with the same bot token.

Dropping the text dump. Keeping one line per send with the message_id
Telegram assigns — that is the line that made the second sender findable,
and a payment feed is worth being able to audit.

Confirmed fixed: the payout at 17:47 posted exactly once to each feed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:50:28 -05:00
martbost 5b2dcb943a Post each on-chain event once per feed, permanently
Marty is still seeing duplicate payment lines, and the instrumented log
finally showed enough to act on rather than keep hunting.

Two things were wrong.

The dedupe I added earlier keyed on message TEXT within 15 minutes, which
fails in both directions. The log caught it swallowing a legitimate post:
two different registrations generate identical "the team just grew" copy,
so the second one was suppressed as a duplicate. Meanwhile anything
carrying a different transaction link sails straight past it.

And it only ever lived in memory, so it could not help across a restart or
across the moment during a deploy when two containers are both alive and
both tailing the chain.

The key is now the on-chain event itself — type, transaction, the member
ids, the amount — recorded per destination chat in a file on the volume,
written before the request goes out. Same event, same feed, one post, no
matter how many times anything calls it or how many processes are running.
Distinct events are never confused, because the transaction hash is in the
key.

All four feeds carry it: team-build topic, recruit topic, proof channel,
shared payments topic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:06:48 -05:00
martbost 8987451cf6 Carry the placement choice with the referral too
Auditing the rest of the prospect paths after Terry's leak. Every entry
point funnels through /join/<id> — the Telegram Mini App redirects there,
member page-builder pages link there, the public /my/<id> share panel
links there — so the cookie covers all of them.

One gap left: ?direct=1 is the inviter's explicit decision to take the
join under themselves rather than route it down their leg, and only the
id was being remembered. A prospect who opened a direct link and then
wandered through the nav came back through the moving link instead — the
opposite of what the shared link asked for.

The flag now rides along in its own cookie, and both /start and /join-now
prefer an explicit choice over the position's default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:25:34 -05:00
martbost 2b36a29a68 Stop the invite being lost when a prospect clicks through the nav
Terry hit this: he shares /join/840, his prospect taps "Training" in the
nav, and by the time they reach /start the site has forgotten him and
offers the COMPANY rotation position — #148. His referral, handed to a
stranger, because the prospect read the training first.

The invite now outlives the page it landed on:

- Serving /join/<id> sets an rmc_ref cookie (30 days, Lax).
- /start reads it (or an explicit ?ref=) and shows the INVITER instead of
  the company rotation, routed by exactly the same rules the inviter's own
  page uses — direct placement if their position is set that way, else the
  next open spot in their leg. The two pages can no longer disagree.
- The Join button carries ?ref through to /join-now, which already knew how
  to place a ref correctly but was never being given one from here.
- /join-now also falls back to the cookie when it arrives with no ?ref.

The page now says whose team it is ("Invited by Member #840") rather than
an anonymous "current placement", and when the inviter is already
qualified it says plainly that the entry fills the next open spot in their
team.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:18:23 -05:00
martbost 7f7732b441 Log the Telegram message_id we get back, to settle which copy is ours
RM Circle has one indexer, one sender, one proof-channel send site, and the
instrumented log shows it sending each payout exactly once. Yet duplicates
keep appearing in the channel, so a second sender with the same bot token
is posting the other copy.

Recording the message_id Telegram assigns makes the next occurrence
decisive: if both visible copies carry ids this process logged, it sent
twice; if only one does, the other sender is external and the id gap says
roughly when it posted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:55:56 -05:00
martbost 653f4f94be Standalone presentation page: the video, subtitles, nothing else
Marty needs a link he can send that shows the full team-build presentation
without steering anyone toward a sponsor. So the page carries no header,
no navigation and no join button — whoever shared the link is the person
the viewer goes back to, and a single line under the player says exactly
that.

The player fills the first screen and the subtitle chips sit directly
under it, same as the training videos: English, Italian, French, Spanish,
German and Portuguese, off by default for an English reader and switched
on automatically for anyone who has already picked a language with the
globe button.

The disclaimers are complete but deliberately below the fold, per Marty's
ask — income language, crypto risk, irreversibility, no-advice — reachable
by scrolling rather than sitting on top of the video.

Served at /presentation and /webinar so either link works. noindex, since
it is a link to hand out rather than a page to rank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:12:20 -05:00
martbost 812cbd03b8 Log every Telegram send with a per-process id while tracing duplicate payment lines
Members are seeing payment receipts twice. This process has a durable
announce-once per on-chain event and a send-level dedupe, and neither is
firing, which says the second copy is not originating here. Rather than
keep reasoning about it, log every send with a random per-process id so
the log answers it directly: two ids means a second instance is posting,
one id twice means the dedupe key differs.

Temporary. Comes out once the source is confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:55:51 -05:00
martbost e43b812f97 Refuse to post the same payment line twice, at the sender
Members were seeing the same payout announced twice in the proof channel
and the shared payments topic, and asking whether someone had been paid
twice. The money was never wrong — one transfer, one contract event — but
a payment feed is the last place where a second receipt is acceptable,
because it makes people doubt the ledger.

The indexer's durable announce-once key is the real fix and shipped in
85a6056. This adds the layer behind it: sendTelegramTo refuses to post the
same text to the same chat within 15 minutes, and logs when it does.
Distinct events can never collide — every line carries its own member ids,
amount and transaction hash.

Deliberately at the sender rather than the emitter, so it holds no matter
which upstream path replays an event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:37:10 -05:00
martbost 1284d28891 Keep a member's link inside their own team, and stop the dashboard contradicting the admin
Two faults found chasing why Terry (#840) was routing joins to #148, a
position outside his team.

The leg lookup only considered positions on the curated rotation list.
Terry qualified the same day he joined, so his two directs (#843, #844)
were too new to be on it. With no leg match the code fell straight through
to the GLOBAL company rotation — sending his people to a stranger while
his own directs sat on 0/2. That is the opposite of the team-first
doctrine the moving link exists to serve. There is now a last-resort step
inside the leg: the nearest position still needing its 2, curated or not.
The curated order still wins when it applies; the global rotation is now
only reached when the member's whole leg really is qualified.

Second, the dashboard ignored directDefault entirely. Marty had already
set Terry to direct placement in the admin, which the join page honours —
but Terry's own share panel still told him his link routed to someone
else. Two different answers to "where do my people land". The panel now
says rotation is off and every join lands under him, and explains
?direct=0 for the exception.

Also made the all-qualified case name what it is: joins go to the company
rotation, placed wherever the company is filling, not inside your leg.
That wording is what made #148 look like a bug rather than a fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:08:24 -05:00
martbost 2f4b70c886 Members can take it back off, and OPTIONAL is now impossible to miss
Three things, one of which we were quietly getting wrong.

REAL OPT-OUT. We told members "removable any time" in three separate places and there
was no way to remove anything. Same class of failure as the dead "Add mine" button:
copy written, mechanism never built. The profile card now offers Remove username,
Remove email and Remove everything, and profiles.remove() clears the value while never
touching the position. Adding it again later works exactly as before, so opting out is
not a one-way door.

It is a two-step inline confirm, not a native confirm() dialog. Browsers with "suppress
dialogs" switched on return false, which would have made Remove look broken in precisely
the way Add mine was broken. First tap arms and explains the consequence, second tap
does it, and it disarms itself after six seconds.

THE PROMISE WE WERE BREAKING. The payout mailer and the upgrade alerts read
member-alerts.json, NOT profiles.json. So a member who completed the new profile got
NOTHING, while the invitation card promised "a note the moment POL lands in your
wallet". Verifying a profile email now mirrors into member-alerts.json so every existing
alert path works, including the unsubscribe link, and removing the email clears both
stores so opting out actually stops the email.

MANSON'S HUGE ASTERISK. He asked for it to be bigger and bolder so nobody can say they
did not see it, and on a decentralized build that burden is ours, not the member's. One
gold badge now appears on the dashboard invitation, inside the dialog on every step, in
the inbox banner and on the profile card itself: "100% OPTIONAL - never required", with
the plain statement that the position, the payouts, the team and everything on the page
work exactly the same without it, nothing on chain depends on it, and it can be removed
again any time.

gate-e2e is 52 assertions, up from 38. The new ones prove one tap does NOT remove
anything, the second tap does, the server agrees the value is gone, an email-only
removal leaves the username alone, and the invitation reappears afterwards so the whole
thing is reversible. profiles-unit 28, signin-fallback 7, captions-e2e 158 all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:52:43 -05:00
martbost b3385ca3f8 Captions: translated subtitles for the training videos, no re-render, no re-voicing
Manson asked whether the training videos could be in other languages. A real dub
means re-rendering every video per language: translated Romance-language speech runs
15-25% longer than English and these are slide videos with fixed beat timings, so
swapped audio drifts off what is on screen. That turns 15 files into 75 and makes
every future lesson edit a five-way job. Captions keep ONE video and add small text
tracks beside it, so editing a lesson re-captions that lesson only.

tools/captions.mjs: ffmpeg pulls the audio, ElevenLabs Scribe transcribes with word
timings (the same STT we already use to verify voiceovers), words are grouped into
SENTENCES, and only then translated. Translating cue-by-cue is why most auto-captions
read badly at cue boundaries. Translation goes through our own /api/public/translate,
so every phrase caches forever in translations.json and costs once across the site.
The STT response is cached on disk because it costs money; never pay for it twice.

Language choice is evidence, not instinct. The translation cache shows real member
demand: Italian and French far ahead, then Spanish, then GERMAN - which beats
Portuguese by more than double, the opposite of what we assumed. Proof of concept is
Italian on the 5-minute overview.

Captions are deliberately NOT on by default. An English reader does not want them
forced over the picture; someone who already switched the site to Italian almost
certainly does. public/vtt-lang.js shows the track matching their 🌐 choice and
leaves the player's CC button to do the rest.

Two bugs this caught in my own code, both found by reading the output:
- the line wrapper truncated each cue to two lines and SILENTLY DELETED the overflow,
  so "the whole plan fits in one sentence" shipped as "fits in" then "sentence". It
  now chunks by the real wrapped line count and never drops a word.
- a one or two word tail ("sentence." alone on screen) folds back into the previous
  cue.
The suite asserts all 780 transcript words survive into the English track.

Also: .vtt had no Content-Type mapping, so it served as octet-stream and browsers
silently ignore such a track. qa/captions-e2e.mjs (12 assertions) reads the parsed
cues back out of the player rather than trusting the markup, which is the only way to
catch that class of failure. profiles-unit 28 and gate-e2e 33 still green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:06:34 -05:00
martbost 91a6893df9 Member profiles are optional: an invitation on the dashboard, never a gate
Manson's objection was that requiring a username and a verified email pulls the
build back toward a centralized database of members. He is right, and the
communication gap is real too, so the answer is to ask well rather than to force.

Nothing about holding a position, getting paid, reading the org, the training or
the tools depends on contact details any more. There is no onboarding gate: a
brand-new member registers, lands on their page and is never stopped by a modal.
The dashboard offers a dismissable card ("Not now" snoozes it for a week) that
leads with the thing members actually want, a note the moment a payout lands in
their wallet, and says outright that everything works the same without it. The
inbox is the one place that asks, because a message cannot be delivered to
someone who left no way to reach them, and even there it is an invitation.

The card sits above the tab strip rather than inside the dashboard pane: the page
opens on the pitch tab, so an invitation parked in the dashboard would never be
seen by the new members it is aimed at.

For leaders, /api/public/reach answers "how many of my org can I reach off the
site", scoped by chain.isInTeam so it leaks nothing upward or sideways. That
makes coverage a leader's own problem to solve by asking, not a rule imposed on
members.

Fixes a real bug found by the rewritten suite: the dismissable flag double-booked
as "single-field edit", so saving a username in the opt-in flow closed the dialog
instead of advancing to the email step. Split into oneShot; the suite now asserts
the advance as a regression.

QA, all green: profiles-unit 28, signin-fallback 7, gate-e2e 33 (rewritten to
assert the opposite of what it used to: no forced modal, dismissable everywhere,
visitors unaffected), join-flow 12 cold / 11 refuse / 12 warm.

qa/reseed.sh carries two hard-won guards: never name a shell variable TMP on
Windows (it inherits the system temp dir and rm -rf wipes it), and never pkill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 04:44:09 -05:00
martbost 49a0778a53 Post-join sign-in actually works: live position lookup for a cold index, ordered calls, redirect no longer races the signature
#787 registered at 18:35 CT, 22 minutes after the first join-flow fix, and still had no profile. Two
causes, both fixed:

1. messages.verifyChallenge resolved the wallet through chain.memberIdByAccount, which reads the
   CACHED index. Seconds after a registration that wallet is not in it, so the signature was rejected
   with "No RM Circle position is registered to this wallet". It now accepts an idHint (the position
   id from the member's own registration receipt) and, on a cache miss, reads that id live from the
   contract via chain.verifyMember, minting only when the contract says this exact wallet owns it.
   That is a stronger proof than the cache, not a weaker one. Now async; the single call site awaits.

2. join-now.js fired the sign-in and a 4.5s redirect in parallel, so the page could navigate away
   while the wallet was still showing the signature prompt, and it did not wait for submit-id (which
   runs the live verifyMember server-side that seeds the index). It now awaits the report, passes the
   receipt id, and redirects only once the signature settles, with a 120s bailout.

qa/signin-fallback.mjs (7 assertions) proves the cold-index path with real secp256k1 signatures and
covers the abuse cases: a hint for a position the wallet does not own is refused, and a signature from
another wallet is refused. Existing suites still pass: profiles-unit 28, gate-e2e 47.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:49:33 -05:00
martbost 2f90e1a97f QA pass on the member profile gate: stable button ids, completion opens the dashboard tab, local devCode, 47-test E2E suite
Findings fixed: the gate's buttons had no stable ids (fragile to test and maintain), and finishing the
gate left the member on the pitch tab where the profile card and Messages are not visible, so
completion now opens the Position Dashboard tab. /api/public/profile/email-start returns devCode
outside production so the flow is testable locally, matching the InstantAdPay pattern.

qa/profiles-unit.mjs (28 assertions) and qa/gate-e2e.mjs (47 assertions, real sessions, real UI) with
a README. All pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:40:03 -05:00
martbost dd74ded287 Profile read endpoint answers 200 signedIn:false for visitors, so a shared /my link logs no console error
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:05:05 -05:00
martbost 7d07fc01f1 Required member profile: username + verified email per position, gated at proof of ownership
Marty, 2026-09-16: leaders can write but 94% of positions cannot receive (47 of 771 have ever signed
in to messaging, 482 messages sit 85% unread). profiles.js stores username + verified email per
POSITION (one wallet holds one position, so a Triple Play holder has three; the person is the email
and one email may hold several positions). Seeds the 40 emails already on file from
member-alerts.json, pre-filled but unverified so confirming costs one tap.

Writes are only ever accepted from a session that PROVED ownership: wallet personal_sign
(messages.verifyChallenge) or the Telegram Mini App bridge. The public /my/<id> page is untouched and
cannot write a profile, verified by test: all four endpoints 401 unauthenticated while /my/21 stays
200. Endpoints GET /api/public/profile, POST .../username, .../email-start, .../email-verify, plus
GET /api/admin/profiles for coverage. Email codes: 6 digits, 15 min, 60s cooldown, 5/day, 6 tries.

profile-gate.js is a two-step modal that cannot be dismissed, fired on dashboard boot (covers the
Mini App landing) and right after a wallet sign-in. Chatbot canned answer + AI prompt updated.
28 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:56:35 -05:00
martbost 8521f7a3f2 Triple Play: highlight the Corona catch (Gen 8 Corona buys pay your Gen 1, which is you) on how-pay-works, tools objection bank, chatbot canned + AI prompt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:46:11 -05:00
martbost c371149507 Chatbot: canned + AI-prompt answer for reading the generation table (level you hold vs level they buy; Vertex row is gen 7, not a total)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:35:57 -05:00
martbost cbfc642187 Upgrade promo overlay: flyer shown once per session on the dashboard and Mini App (announce.js promo mode, admin-configurable promo* keys) 2026-09-16 04:22:53 -05:00
martbost 4b982dffe0 Team-build topic: telegramTeamEvents=none leaves only the daily snapshot
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 07:50:20 -05:00
martbost 5cbd47db27 Team-build topic: config switch to drop payout lines (telegramTeamEvents=no-payouts)
Payout notifications migrate to the shared payments topic; registrations,
upgrades and the daily snapshot keep posting to the team-build topic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 05:28:26 -05:00
martbost 876b6eebd8 Echo payout proof lines into a shared cross-program Telegram topic
New config keys telegramEchoTopicId / telegramEchoChatId / telegramEchoEvents
(default: payouts). Each echoed line is prefixed with the program name because
InstantAdPay posts into the same topic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 18:50:36 -05:00
martbost 13eabc6b9a Payment proof card points to the public RM Circle Proof channel (t.me/rmcircleproof)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 05:11:25 -05:00
martbost 996c5b2a69 Show the Telegram payment channel in the live payment proof section (home + join)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 04:57:13 -05:00
martbost b9c079fa3e Route /announce.ics through the API handler
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:04:41 -05:00
martbost 359bfee3db Event pop-up: Add-to-calendar buttons (Google Calendar link + .ics for Apple/Outlook)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:02:07 -05:00
martbost 6c2306966d Proof channel: one compact line per event (payouts + upgrades), HTML link, no hashtags
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 09:46:56 -05:00
martbost 617e519a3b Proof channel posts link to the team site, not a personal link
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 08:54:50 -05:00
martbost 42a6caacb0 Telegram: optional company payment-proof channel feed (same bot, separate chat)
config.telegramProofChatId (+ optional telegramProofTopicId, telegramProofEvents
'payouts'|'all') mirrors payout events with the recruiting copy plus the
Polygonscan receipt. Inert until the chat id is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 07:03:23 -05:00
martbost 946a9d75b5 Gate consistency video to members; embed on rhythm page; add vertical cut
- Members-only: the stay-consistent video now requires a wallet-verified session
  (server 403 + training-page lock UI), matching Module 3.
- Embedded on /weekly-rhythm above the sheet (hidden in print) with a members-only
  fallback card for signed-out visitors.
- Added a ~31s vertical (1080x1920) social cut, ungated, for Telegram/social.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 06:08:46 -05:00
martbost e3f6b00f95 Make event pop-up admin-toggleable; drop it from join pages
The Weekly Huddle overlay now reads its content from /api/announce (config-
driven) instead of a baked-in block, and Admin -> Settings -> Event pop-up
exposes a toggle plus editable fields (eyebrow, flyer, join URL, date, times,
auto-hide, event id). Defaults are seeded into config on boot so the panel
reflects real state. Removed the include from join.html so it never interrupts
the join flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 05:28:03 -05:00
martbost 44651a92b7 Textbook Play copy: Marty is a top team builder, not a program founder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 15:11:33 -05:00
martbost 106f0fa7bd Training: 'The Textbook Play' coaching video — a real position run by the book
111s in Marty's voice: position #21's ledger, catch-funded climbs, the
pass-over cost, this week's Apex catch-and-climb, and the dashboard/sponsor
next-play close. Chatbot + AI prompt synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 14:54:06 -05:00
martbost 79470e471c Members area: weekly webinar replay page at /replays
Wallet-gated like the Method lessons: the API returns titles/dates to
everyone (prospects see the shape of the product) and video URLs only with a
member session. List lives in the data volume (replays.json) so the weekly
add never needs a deploy; videos stream from the DO Spaces bucket (CSP
media-src added). Teaser on /training; chatbot + AI prompt updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 06:46:06 -05:00
martbost 8ea371dd09 Telegram team posts: disable link previews on payout/team-build notifications
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 05:12:40 -05:00
martbost 747977b368 Catch eligibility: catcher needs level >= the level the buyer is LEAVING, not the level being bought
Matches _payUpline (level > levelIndex, levelIndex = buyer.level-1). The old
depth+1 rule told #21 (Culmen) it could not catch #49's Apex buy; #21 is #49's
4th matrix upline and does catch it. Fixed in the owner alert, coaching scan,
dashboard pipeline, AI prompt, leader digest, chatbot and how-pay-works copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:51:30 -05:00
martbost 2d1ccadfd9 Suite: let members switch between positions they hold
A member holding #2 and #3 connected #3's wallet, closed the browser, came
back, and still saw #2. Not a wallet problem: the sign-in cookie lasts 30 days
and there was no sign-out anywhere on the site, so the first position he
authenticated as was pinned to that browser and the wallet was never consulted
again.

This hits precisely the people we tell to buy several positions - the Triple
Play is on /how-pay-works and in the chatbot - so it will keep happening.

Adds POST /api/public/signout (drops the server session and expires the
cookie) and a "not this position? switch" link beside the identity line on
/suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:07:29 -05:00
martbost c039c04ceb Suite: entitle the positions above our team root, not just below it
The org test only ever looked downward: you are in if a root appears in your
upline chain. The founders and contract owners sit ABOVE #21, so #2 - a Corona
position that has been here since the beginning - was told "this position isn't
inside our team's organization" and shown a locked wall.

That is backwards. Their positions are the reason the contract exists.

Each root's own upline is now entitled as well, which covers the chain up
through #2 and the root account. A position's upline is fixed at registration,
so it is resolved once and memoised rather than walked per request.

Nothing else changes: level still comes from the contract, the allowlist and
level-override behave as before, and members below the root are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:51:16 -05:00
martbost 7e390aa48e Join: let chosen positions default to direct placement
The moving link is team-first by design: it routes a new join to whoever needs
directs next, so the position whose link was actually clicked earns no entry
reward. Measured on #21 that came to 80 joins in seven days and zero entry
rewards, while the traffic paying for those joins was the owner's.

For a position being deliberately built out, that default is backwards. Any ID
listed in the new directDefaultIds config now behaves as ?direct=1 on its own
/join/<id> link - including every promo tool, flyer QR and downline-builder
entry that resolves through it - without needing the parameter appended.

?direct=0 still forces rotation for a single share, so the team-first behaviour
stays available rather than being replaced.

Config rather than hard-coded, and editable from the admin settings form, so
which positions get this can change without a deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 10:29:55 -05:00