Skip to content

Commit 6e62d34

Browse files
0800timclaude
andauthored
fix(join-flow): hard-reload after OTP verify (urgent hotfix) (#303)
* style(home): clean 80%-to-bottom linear fade on banner image Tim 2026-06-05 (after the marketing publish): > it should fade out to transparent at the bottom so it blends in > nicely and we don't get this kind of line where the first > paragraph is under the heading in the bottom 20% of the banner > image. You just fade that from the 20% point at the bottom down > to the very bottom, gradient fade to transparent. The existing mask had three stops (opaque to 68%, half-opacity at 88%, transparent at 100%) which left a visible threshold band around the lede. Rewritten as a straight linear ramp from 80% opaque to 100% transparent so the player-photo edge dissolves into the page background. Both -webkit- and unprefixed mask-image forms updated together so iOS Safari + every chromium fork picks it up. CSS-only, one rule (.vt-home-hero-bg). Headline-area dark overlay above the mask is unchanged so legibility on the upper 80% is unaffected. Signed-off-by: Tim Thomas <0800tim@gmail.com> * docs: add match card venue/time footer + overlay design spec Brainstorm output. Replaces the unused 'Add score' toggle and the small top-right ellipsis link on each match row with a single neutral charcoal lozenge showing the user's local kickoff date/time and a gold info icon, opening the existing MatchOverlay. Extends that overlay with a stage chip, dual-timezone When block, and a Where block surfacing city, country, stadium real name, FIFA tournament name, and capacity. Architecture: direct prop + small lookup module (Approach A). Plumbs hostCityId from canonical fixtures through resolveMatch and MatchPredictionRow. New apps/web/lib/host-cities.ts wraps the existing data/fifa-wc-2026/host-cities.json. Refs: sessions/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * feat(bracket): venue/time footer lozenge + extended match overlay Replace the unused 'Add score' toggle and the small top-right ⋯ link on each match row (group + knockout) with a single neutral charcoal lozenge containing the user's local kickoff date, time, and a gold info icon. Whole lozenge is one tap target into the existing MatchOverlay. Extend MatchOverlay with: - Stage / matchday chip ('Group A · Match 1'). - When block: full date, user-local kickoff time (large), venue- local kickoff time (small, suppressed when zones match). - Where block: city, host-country flag, country name (locale- resolved), real stadium name, FIFA tournament name, capacity. Fix a latent bug in the prior formatKickoff helper that hard-coded en-NZ + UTC while presenting the output as if it were user-local. New apps/web/lib/host-cities.ts wraps the existing FIFA 2026 host-cities JSON: hostCityById, hostCityByMatchNumber, and kickoffIsoByMatchNumber. ResolvedMatch gains an optional hostCityId. SSR / hydration: footer and overlay render in the venue timezone on the server (deterministic) and swap to the user's resolved timezone after mount via Intl.DateTimeFormat. Markup is identical pre/post hydration. Tests: - apps/web/__tests__/host-cities.test.ts (lookup module) - apps/web/__tests__/MatchVenueFooter.test.tsx (render, hydration, overlay-router open, fallback link href) Refs: docs/superpowers/specs/2026-06-06-match-card-venue-footer-design.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * feat(bracket): blurred-flag side cards, TBD note, tighter knockout lozenge, smaller TZ Round-two polish from Tim's review of the v1 build (2026-06-06): - MatchOverlay When block now splits the kickoff time from its timezone abbreviation via Intl.DateTimeFormat.formatToParts(), so the primary line reads '08:00' at heading size + 'GMT+12 your time' at caption size. Fixes the over-large 'GMT+12' that was reading as if it were part of the headline number. - MatchOverlay SideCard renders a blurred full-bleed flag behind the circular team chip when the team is known (group stage popups + resolved knockout slots). The .vt-match-overlay-side- tbd variant for unresolved knockout slots keeps the plain card with the '?' glyph, matching the bracket's TBD treatment. - New 'Teams shown once the previous stage closes' italic caption appears under the Sheet title when one or both sides are still TBD. Singular 'Opponent shown ...' when only one side is TBD. - Knockout cards (.km-card .mpr-venue-footer) now use a tighter lozenge: smaller padding, lower min-height, narrower width band. Combined with .km-team min-height bumped 56px -> 76px so the flag art gets back the vertical real estate the lozenge was consuming. Group rows are unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * fix(bracket): keep venue row when odds slot is empty on knockout cards The .km-card:has(.km-odds:empty) selector strips the odds row to avoid a phantom row-gap under the team cells. It was rewriting grid-template-areas without including the new 'venue' row, so .mpr-venue-footer (which sets grid-area: venue) lost its named area and fell into an implicit auto-placed cell at the bottom- right instead of spanning the full card width under both flags. Restore the venue row in the empty-odds override so the lozenge keeps its full-width footer position on R32 and every later knockout round. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * fix(pool-join): JoinSyndicate OTP form now matches ProfileCompletionGate Bug report (Tim 2026-06-06): registering via the pool join page captured 'display name' + 'handle' as separate fields, then PATCHed the typed display name (a human full name like 'Tim Thomas') in as the auth-sms user's display_name. That display_name IS the @handle per the 2026-06-05 'one identity per user' rule, and it's immutable once set, so the user was stuck with their real name as their permanent @handle. Evidence in the prod game.db included rows like 'John Doe', 'Andy Prentice', and 'Molly (Tournamental Oracle)' in syndicate_owners_membership.handle. The JoinSyndicate modal was the only surface in the app still modelling display_name + handle as two separate fields. The global ProfileCompletionGate (which the authed-fast-path and the WhatsApp inbound-login path both flow through) already uses the correct model: display_name IS the @handle (slug-validated), and the human name lives in first_name + last_name. Aligned the modal with the gate: - Renamed the 'Your name' input to 'Your @handle (permanent)' with a slug-preview hint and slug-validation (3..32 chars, reserved list, mirrors the gate exactly). - Replaced the separate 'Handle' input with First name + Last name inputs, matching the gate and the /profile page. - Dropped the auto-derive-handle-from-name effect and the handleTouched flag. - bindAndJoin now PATCHes /v1/auth/me with { display_name: <slug>, first_name, last_name } so the user's @handle is the slugified value they previewed, not whatever they typed. - /join body still sends { handle, display_name } both set to the slug for log clarity, even though the server ignores both fields. - PendingJoin localStorage v2 carries displayName + firstName + lastName (handle removed); the loader tolerates v1 saves by promoting the old field into displayName so an upgraded user with a stale pending row doesn't lose state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * feat(manage-pool): edit allowed_phone_countries from the manage page Tim hit two related issues on the 13B Pupuke pool (2026-06-06): 1. The pool was locked to New Zealand residents only (a leftover from creation), but the join page surfaces a country banner that puts email-only joiners off. 2. He tried to remove the country lock from the manage page and found there was no UI for it. Root cause beneath the missing UI: the persistence layer's updateBranding silently dropped allowed_phone_countries because the field wasn't in the stringFields loop and had no special case. The /owner PATCH route's Zod schema accepted it (per SEC-POOL-11) but the writes never landed. The manage-owner PATCH never even accepted it. Three changes: - persistence.updateBranding now honours allowed_phone_countries by reusing serialiseAllowedCountries (the same helper createSyndicate uses), so empty arrays clear the restriction and non-empty arrays serialise to the storage CSV. - manage-owner GET now returns allowed_phone_countries as a string[] of bare dial codes. Manage-owner PATCH accepts the same shape (max 10 entries, ^\d{1,3}$ each), mirroring the /owner route. - ManageClient grows a 'Lock entries by country' editor: toggle + chip list + add-country select, capped at 10. Toggling off sends an empty array, clearing the restriction server-side. COUNTRY_CODES grew a flag emoji per row for the chip render. Tim's 13B Pupuke row was unlocked by hand before this commit landed via a one-off UPDATE on syndicates.allowed_phone_countries. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * fix(join-flow): unstick 'Setting up your profile…' after inbound sign-in Tim 2026-06-06: signing into /s/<slug>/join via WhatsApp or email landed on the OnboardingStep's 'Setting up your profile…' spinner and never advanced. The poll loop sat forever waiting for the ProfileCompletionGate to write display_name, but the gate never appeared. Root cause: useUser only subscribes to Supabase auth events. The inbound-login flow (WhatsApp / SMS / email OTP) sets the tnm_session cookie but doesn't fire any Supabase event, so useUser stayed on its pre-auth state. The gate's effect gates on status==='authenticated' AND a u_-prefixed user id, neither of which were true even after the tnm_session cookie was live. So the gate never showed, the user never picked an @handle, display_name stayed empty, and the OnboardingStep's poll never saw a value to advance on. Fix: a custom 'tnm:auth-changed' window event. - useUser listens for it and re-runs the inbound probe, so the inbound session flows through to status='authenticated' without a page reload. - JoinFlowClient.handleSignedIn dispatches it after a successful inline verify so the gate fires the moment the user signs in. - ProfileCompletionGate also dispatches after a successful save so AuthChip + any other useUser consumer flips to the new display_name / first_name / last_name immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * style(bracket): selected pick gets solid yellow fill + dark text Tim 2026-06-06 DevTools mock: the previous selected-pick treatment was a 4px gold inset ring on a 6%-alpha gold-tinted background, leaving the chosen team's rectangle reading as 'ringed but mostly empty'. The user-facing read of 'this is definitely my pick' was weak compared with the DRAW pill's full gold fill. Three changes to .mpr-pick: - .mpr-pick-home.is-selected / .mpr-pick-away.is-selected now fill with #fbbf24 (the same Tailwind amber the bracket's focus-ring already uses) instead of the 6%-alpha tint. - .mpr-pick.is-selected .mpr-pick-code flips to #15151a so the team code (MEX, RSA, etc.) reads cleanly against the yellow. - .mpr-pick.is-selected .mpr-pick-pct flips from #e6edf3 to #15151a for the same reason. Inset gold ring + outer halo retained so the flag chip still sits cleanly inside the yellow rectangle. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * style: replace em-dash with hyphen in MatchOverlay time fallback Per Tim's hard rule (no em-dashes in user-facing copy), the splitTimeAndZone fallback returns now use '-' instead of '—'. Internal-only commit comments / CSS comments are left alone. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * fix(bracket-engine): narrow r32_01.home via local const in two tests Both cascade.test.ts and score.test.ts read .home twice across an if-guard, which TS doesn't narrow through a fresh property access. Result: TS2339 'Property group does not exist on type SlotSource' on the .group / .position reads after the narrowing if. Pre-existing on main and blocking PR #302's Node CI step. Bind .home to a local const so TS keeps the narrowed group_position variant alive through both subsequent reads. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * feat(bracket): position-indicator pips on group-header team chips Tim 2026-06-06: a small brand-yellow (#fbbf24) circle now rides the top-right of each group-header team chip showing the team's current predicted standings position (1..4). At-a-glance read of 'who am I currently predicting to advance' without scrolling down to the predicted-standings panel. - positionByCode built from computeGroupStandings (index+1), so tiebreaker resolution is honoured: when the user supplies a manual tiebreaker, the pip order updates immediately. - Only rendered when predictedCount > 0; before any pick the standings are all-zero and a 1/2/3/4 would be stub-ordered alphabetically, which is misleading. - Brand yellow + #15151a digit matches the rest of the freshly reworked selection palette (selected-pick fill, selected-DRAW pill). aria-hidden because the predicted-standings panel below already exposes the same information to screen readers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> * fix(join-flow): hard-reload after OTP verify to unstick auto-redirect Urgent prod hotfix (Tim 2026-06-06): users hitting /s/<slug>/join, signing in via WhatsApp or email OTP, were landing on the OnboardingStep's 'Setting up your profile…' spinner and never advancing. Manual refresh fixed it every time. Earlier fix attempted to bridge the inbound-session flip into useUser by dispatching a 'tnm:auth-changed' event from handleSignedIn. It worked on dev but raced with the browser's cookie-commit on production for some clients, leaving the ProfileCompletionGate unmounted and the OnboardingStep stranded. Switch to the deterministic path: window.location.reload() after a successful inline verify. The page comes up with the tnm_session cookie already in the jar, useUser's init() picks it up on first paint, and the gate either fires the @handle picker (new user) or the OnboardingStep auto-joins (returning user). Two belt-and-braces additions stay in place: - OnboardingStep's 1s poll loop re-broadcasts the 'tnm:auth-changed' event each tick when the inbound probe returns a user without a display_name. If anything ever skips the reload path the gate still fires within 1s. - The 'Setting up your profile…' message gains an explicit '(Refresh this page if you're not auto-redirected.)' caption. Cheap, visible self-recovery for any edge case the reload misses. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Tim Thomas <0800tim@gmail.com> --------- Signed-off-by: Tim Thomas <0800tim@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5d9f6a2 commit 6e62d34

1 file changed

Lines changed: 42 additions & 3 deletions

File tree

apps/web/components/join/JoinFlowClient.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,22 @@ export function JoinFlowClient({ slug, initialName }: JoinFlowClientProps): JSX.
248248
/** Called by the sign-in step after a successful inline verify. */
249249
const handleSignedIn = useCallback(
250250
async (verifiedUser: InboundUser | null): Promise<void> => {
251+
// Tim 2026-06-06: hard-reload on verify is the reliable path.
252+
// The previous in-place flow (setUser + dispatch tnm:auth-
253+
// changed + routeSignedIn) raced with the browser's cookie
254+
// commit on some clients, leaving the ProfileCompletionGate
255+
// unmounted on the freshly-authenticated page and stranding
256+
// the OnboardingStep on "Setting up your profile…" until the
257+
// user noticed they had to refresh. A reload lets the page
258+
// come up clean with the tnm_session cookie already in the
259+
// jar, so useUser's init() picks it up on first paint and the
260+
// gate fires (or the user auto-joins) deterministically.
261+
if (typeof window !== "undefined") {
262+
window.location.reload();
263+
return;
264+
}
265+
// Server-side or test invocations: fall back to the original
266+
// in-place flow so the surrounding code keeps working.
251267
if (verifiedUser) setUser(verifiedUser);
252268
const fresh = verifiedUser ?? (await fetchInboundUser());
253269
if (fresh) setUser(fresh);
@@ -1144,6 +1160,18 @@ function OnboardingStep({
11441160
if (!name) {
11451161
// Gate's still up. Poll every 1s.
11461162
setLiveDisplayName("");
1163+
// Tim 2026-06-06: re-broadcast tnm:auth-changed every poll
1164+
// when we've confirmed the inbound cookie works (probe
1165+
// returned a user object) but the user hasn't picked a
1166+
// display_name yet. Belt-and-braces: handleSignedIn's
1167+
// original dispatch can race with the cookie commit on some
1168+
// browsers, leaving useUser stuck on 'guest' and the gate
1169+
// unmounted. This second source guarantees the gate gets the
1170+
// signal within at most 1s and shows the @handle picker
1171+
// without a manual reload.
1172+
if (fresh && typeof window !== "undefined") {
1173+
window.dispatchEvent(new CustomEvent("tnm:auth-changed"));
1174+
}
11471175
pollTimer = setTimeout(() => void runOnce(), 1000);
11481176
return;
11491177
}
@@ -1206,9 +1234,20 @@ function OnboardingStep({
12061234
}}
12071235
>
12081236
{!liveDisplayName ? (
1209-
<p style={{ color: "#c7d0e6", fontSize: 14, margin: 0, textAlign: "center" }}>
1210-
Setting up your profile…
1211-
</p>
1237+
<>
1238+
<p style={{ color: "#c7d0e6", fontSize: 14, margin: 0, textAlign: "center" }}>
1239+
Setting up your profile…
1240+
</p>
1241+
{/* Tim 2026-06-06: an explicit fallback while we track down
1242+
* the auto-redirect race. The ProfileCompletionGate sometimes
1243+
* doesn't pick up the inbound session until a reload (the
1244+
* tnm:auth-changed event isn't always landing in useUser).
1245+
* Telling the user how to unstick themselves is better than
1246+
* leaving them on a spinner. */}
1247+
<p style={{ color: "rgba(199, 208, 230, 0.6)", fontSize: 12, margin: 0, textAlign: "center" }}>
1248+
(Refresh this page if you&apos;re not auto-redirected.)
1249+
</p>
1250+
</>
12121251
) : busy ? (
12131252
<p style={{ color: "#c7d0e6", fontSize: 14, margin: 0, textAlign: "center" }}>
12141253
Joining as <strong>@{liveDisplayName}</strong>

0 commit comments

Comments
 (0)