+
+ );
+}
+
+// ---------- helpers ----------
+
+function countryFlagEmoji(iso2: string): string {
+ if (iso2.length !== 2) return "";
+ const a = iso2.toUpperCase().charCodeAt(0);
+ const b = iso2.toUpperCase().charCodeAt(1);
+ if (a < 65 || a > 90 || b < 65 || b > 90) return "";
+ return String.fromCodePoint(0x1f1e6 + (a - 65), 0x1f1e6 + (b - 65));
+}
+
+function countryDisplayName(iso2: string): string {
+ try {
+ return (
+ new Intl.DisplayNames(undefined, { type: "region" }).of(
+ iso2.toUpperCase(),
+ ) ?? iso2
+ );
+ } catch {
+ return iso2;
+ }
}
diff --git a/apps/web/components/overlay/team-overlay.css b/apps/web/components/overlay/team-overlay.css
index 95d74d8e..f960f025 100644
--- a/apps/web/components/overlay/team-overlay.css
+++ b/apps/web/components/overlay/team-overlay.css
@@ -160,6 +160,24 @@
color: #fbbf24;
}
+/* Stage chip, single rounded lozenge anchored at the top of the
+ * sheet. Replaces the older bare `.vt-match-overlay-stage` span and
+ * mirrors the visual vocabulary of the bracket's team chips and DRAW
+ * pill so the overlay reads as in-family. */
+.vt-match-overlay-stage-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 12px;
+ border-radius: 999px;
+ background: rgba(220, 169, 75, 0.08);
+ border: 1px solid rgba(220, 169, 75, 0.4);
+ color: #dca94b;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
.vt-match-overlay-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
@@ -168,21 +186,68 @@
}
.vt-match-overlay-side {
+ position: relative;
display: flex;
flex-direction: column;
- gap: 6px;
+ gap: 8px;
align-items: center;
text-align: center;
- padding: 14px 8px;
+ padding: 16px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: inherit;
font: inherit;
cursor: pointer;
+ overflow: hidden;
+ isolation: isolate;
transition: background 120ms ease-out, border-color 120ms ease-out;
}
+/* Blurred full-bleed flag behind the circular chip + name. Only
+ * present when the side has a known team โ the .vt-match-overlay-
+ * side-tbd modifier (knockout cards with unresolved slots) skips
+ * the background entirely (Tim 2026-06-06). Same visual vocabulary
+ * as `.km-team::before` so the knockout cards and group popups
+ * read as one piece of brand chrome. */
+.vt-match-overlay-side[data-team-bg]::before {
+ content: "";
+ position: absolute;
+ inset: -10px;
+ z-index: -1;
+ background-image: var(--vt-side-bg);
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ filter: blur(8px);
+}
+.vt-match-overlay-side[data-team-bg]::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ border-radius: inherit;
+ background: linear-gradient(
+ 180deg,
+ rgba(0, 0, 0, 0.22) 0%,
+ rgba(0, 0, 0, 0.45) 55%,
+ rgba(0, 0, 0, 0.7) 100%
+ );
+}
+.vt-match-overlay-side[data-team-bg] > * {
+ position: relative;
+ z-index: 1;
+}
+[data-theme="light"] .vt-match-overlay-side[data-team-bg]::after {
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.55) 0%,
+ rgba(255, 255, 255, 0.4) 55%,
+ rgba(255, 255, 255, 0.2) 100%
+ );
+}
+
.vt-match-overlay-side:hover,
.vt-match-overlay-side:focus-visible {
background: rgba(255, 255, 255, 0.07);
@@ -222,6 +287,108 @@
font-weight: 500;
}
+/* ---------- When block (dual-timezone kickoff) ---------- */
+
+.vt-match-overlay-when {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 12px 0;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+[data-theme="light"] .vt-match-overlay-when {
+ border-top-color: rgba(15, 23, 42, 0.06);
+ border-bottom-color: rgba(15, 23, 42, 0.06);
+}
+
+.vt-match-overlay-when-date {
+ font-size: 13px;
+ opacity: 0.75;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.vt-match-overlay-when-row {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ font-variant-numeric: tabular-nums;
+}
+
+.vt-match-overlay-when-primary .vt-match-overlay-when-time {
+ font-size: 22px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.vt-match-overlay-when-secondary .vt-match-overlay-when-time {
+ font-size: 14px;
+ font-weight: 500;
+ opacity: 0.85;
+}
+
+/* TZ abbreviation sits next to the big time but at caption size so
+ * the eye reads "08:00" as the headline number and "GMT+12 your time"
+ * as the supporting detail (Tim 2026-06-06). */
+.vt-match-overlay-when-tz {
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ opacity: 0.7;
+}
+
+.vt-match-overlay-when-caption {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ opacity: 0.6;
+}
+
+/* Small dimmed caption rendered under the Sheet title when one or
+ * both teams are still TBD (knockout cascade hasn't resolved yet).
+ * Lives inside the overlay body, above the stage chip, in italic so
+ * it reads as a parenthetical note rather than copy. */
+.vt-match-overlay-tbd-note {
+ margin: -8px 0 0;
+ font-size: 12px;
+ font-style: italic;
+ opacity: 0.6;
+}
+
+/* ---------- Where block (venue detail) ---------- */
+
+.vt-match-overlay-where {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.vt-match-overlay-where-city {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ opacity: 0.75;
+ letter-spacing: 0.02em;
+}
+
+.vt-match-overlay-where-flag {
+ font-size: 16px;
+ line-height: 1;
+}
+
+.vt-match-overlay-where-stadium {
+ font-size: 16px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.vt-match-overlay-where-meta {
+ font-size: 12px;
+ opacity: 0.65;
+}
+
.vt-match-overlay-actions {
display: flex;
justify-content: center;
diff --git a/apps/web/components/share-landing/JoinSyndicate.tsx b/apps/web/components/share-landing/JoinSyndicate.tsx
index c7161560..54491bc1 100644
--- a/apps/web/components/share-landing/JoinSyndicate.tsx
+++ b/apps/web/components/share-landing/JoinSyndicate.tsx
@@ -30,7 +30,7 @@
*/
import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
function safeT(
t: ReturnType,
@@ -62,8 +62,10 @@ export interface JoinSyndicateProps {
interface PendingJoin {
readonly slug: string;
- readonly handle: string;
+ /** The user's @handle. Per Tim 2026-06-05, display_name IS the @handle. */
readonly displayName: string;
+ readonly firstName: string;
+ readonly lastName: string;
}
const LS_PENDING_JOIN = "tnm.pending_join.v1";
@@ -81,9 +83,23 @@ function loadPending(): PendingJoin | null {
try {
const raw = window.localStorage.getItem(LS_PENDING_JOIN);
if (!raw) return null;
- const j = JSON.parse(raw) as PendingJoin;
- if (j && typeof j.slug === "string" && typeof j.handle === "string") return j;
- return null;
+ const j = JSON.parse(raw) as Partial & { handle?: string };
+ if (!j || typeof j.slug !== "string") return null;
+ // Tolerant load: the v1 schema stored a separate `handle`. v2 uses
+ // displayName as the @handle directly. Migrate old saves so an
+ // upgraded user with a stale pending row doesn't lose state.
+ const displayName =
+ typeof j.displayName === "string" && j.displayName.length > 0
+ ? j.displayName
+ : typeof j.handle === "string"
+ ? j.handle
+ : "";
+ return {
+ slug: j.slug,
+ displayName,
+ firstName: typeof j.firstName === "string" ? j.firstName : "",
+ lastName: typeof j.lastName === "string" ? j.lastName : "",
+ };
} catch {
return null;
}
@@ -116,15 +132,30 @@ function normalisePhone(raw: string): string {
return t;
}
-function deriveHandleFromName(name: string): string {
- return name
- .trim()
+/** Mirror of `slugify` in ProfileCompletionGate.tsx and the auth-sms
+ * server's slugifyDisplayName. The form's @handle field is what becomes
+ * the user's display_name, and the server slugifies it on save, so the
+ * client-side preview here only exists to give early validation feedback
+ * before submit. */
+function slugifyHandle(input: string): string {
+ return input
.toLowerCase()
+ .normalize("NFKD")
+ .replace(/[ฬ-อฏ]/g, "")
.replace(/[^a-z0-9_]+/g, "_")
- .replace(/^_+|_+$/g, "")
- .slice(0, 32);
+ .replace(/_+/g, "_")
+ .replace(/^_+|_+$/g, "");
}
+/** Mirror of the gate's RESERVED_HANDLES list. Server is the source of
+ * truth; this is for early UI feedback only. */
+const RESERVED_HANDLES = new Set([
+ "admin", "administrator", "api", "www", "play", "you", "me",
+ "anonymous", "anon", "deleted", "support", "help", "tournamental",
+ "official", "staff", "team", "mod", "moderator", "root", "system",
+ "null", "undefined",
+]);
+
type Step = "identity" | "verify" | "success" | "exit";
export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
@@ -141,10 +172,16 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState("identity");
- // Identity step
+ // Identity step. Per Tim 2026-06-05 (cffa1d3 / 2f52efe), display_name
+ // IS the user's permanent @handle (URL-safe), and first_name / last_name
+ // are the separate human-readable name fields. Before this rewrite the
+ // modal asked for a `displayName` (= human name) + a separate `handle`
+ // (= slug), and PATCHed the human name into auth-sms as display_name,
+ // locking it as the user's @handle. Tim hit the resulting "my full name
+ // is now my handle and it's locked" trap 2026-06-06.
const [displayName, setDisplayName] = useState("");
- const [handle, setHandle] = useState("");
- const [handleTouched, setHandleTouched] = useState(false);
+ const [firstName, setFirstName] = useState("");
+ const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState("");
// Email is the WhatsApp-free fallback: either phone or email is
// required, both is fine, and when both are provided we send the
@@ -175,13 +212,10 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
- // Auto-derive a handle from the display name until the user edits
- // the handle field directly.
- useEffect(() => {
- if (!handleTouched) {
- setHandle(deriveHandleFromName(displayName));
- }
- }, [displayName, handleTouched]);
+ // Live-slugified preview of the @handle. The user types what they
+ // want; we show the resulting slug as a hint, and the server
+ // slugifies again on save (it's the source of truth).
+ const handleSlug = useMemo(() => slugifyHandle(displayName), [displayName]);
// Reset state when the modal closes.
const close = useCallback(() => {
@@ -316,13 +350,18 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
}
}, [slug]);
- const handleIsValid = /^[a-zA-Z0-9_]{2,32}$/.test(handle);
- const nameIsValid = displayName.trim().length >= 1;
+ // @handle validity, must slugify cleanly to 3..32 chars and not be on
+ // the reserved list. Same rule the ProfileCompletionGate enforces;
+ // the server re-validates on PATCH /v1/auth/me.
+ const handleIsValid =
+ handleSlug.length >= 3 &&
+ handleSlug.length <= 32 &&
+ !RESERVED_HANDLES.has(handleSlug);
const phoneIsValid = /^\+\d{8,15}$/.test(normalisePhone(phone));
const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identityEmail.trim());
// At least one contact channel must validate; both is fine.
const hasContact = (phone.trim() && phoneIsValid) || (identityEmail.trim() && emailIsValid);
- const canSubmitIdentity = !busy && handleIsValid && nameIsValid && hasContact;
+ const canSubmitIdentity = !busy && handleIsValid && hasContact;
const onSubmitIdentity = useCallback(
async (e: React.FormEvent) => {
@@ -333,9 +372,12 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
setInfo(null);
try {
- // 1) Handle availability check (cheap, no OTP consumed).
+ // 1) @handle availability check against the slugified value (cheap,
+ // no OTP consumed). Server-side uniqueness lives in auth-sms
+ // `display_name`; this endpoint is a quick lookahead so the
+ // user finds out before they paste an OTP.
const checkRes = await fetch(
- `/api/v1/syndicates/${encodeURIComponent(slug)}/handle-check?handle=${encodeURIComponent(handle)}`,
+ `/api/v1/syndicates/${encodeURIComponent(slug)}/handle-check?handle=${encodeURIComponent(handleSlug)}`,
{ method: "GET", headers: { Accept: "application/json" } },
);
const checkJson = (await checkRes.json().catch(() => ({}))) as {
@@ -346,8 +388,8 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
if (!checkRes.ok || checkJson.error || checkJson.available !== true) {
setError(
checkJson.error === "bad_handle"
- ? "That handle isn't valid. Letters, numbers, and underscores, 2โ32 chars."
- : `Sorry, "${handle}" is already taken. Pick a different handle.`,
+ ? "That handle isn't valid. Letters, numbers, and underscores, 3 to 32 chars."
+ : `Sorry, "${handleSlug}" is already taken. Pick a different handle.`,
);
setBusy(false);
return;
@@ -468,8 +510,9 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
if (sendFailed && normalised) {
savePending({
slug,
- handle: handle.trim(),
- displayName: displayName.trim(),
+ displayName: handleSlug,
+ firstName: firstName.trim(),
+ lastName: lastName.trim(),
});
setPhoneMasked(normalised);
setStep("verify");
@@ -496,8 +539,9 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
// At least one channel sent successfully โ persist + advance.
savePending({
slug,
- handle: handle.trim(),
- displayName: displayName.trim(),
+ displayName: handleSlug,
+ firstName: firstName.trim(),
+ lastName: lastName.trim(),
});
if (phoneRes?.ok && normalised) {
setPhoneMasked(phoneRes.phoneMasked ?? normalised);
@@ -522,9 +566,11 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
[
canSubmitIdentity,
slug,
- handle,
phone,
displayName,
+ handleSlug,
+ firstName,
+ lastName,
identityEmail,
phoneIsValid,
emailIsValid,
@@ -538,18 +584,30 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
const bindAndJoin = useCallback(async (): Promise<
{ ok: false } | { ok: true; status: "active" | "pending" }
> => {
- // Bind display name on the auth-sms user (PATCH /v1/auth/me).
+ // Bind the user's @handle (display_name) + first/last name on the
+ // auth-sms record. Per Tim 2026-06-05 display_name IS the @handle;
+ // first/last are the separate human-readable fields. We slugify
+ // client-side for early validation and again before send so the
+ // saved value matches what the user previewed.
+ const handleForSave = slugifyHandle(displayName);
try {
await fetch(`${AUTH_BASE.replace(/\/$/, "")}/v1/auth/me`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ display_name: displayName.trim() }),
+ body: JSON.stringify({
+ display_name: handleForSave,
+ first_name: firstName.trim() || null,
+ last_name: lastName.trim() || null,
+ }),
});
} catch {
/* non-fatal; user can edit later */
}
- // Add to the pool (handle + display_name).
+ // Add to the pool. The /join route ignores the body's handle /
+ // display_name and uses session.displayName as the membership
+ // handle (per the 2026-06-05 'one identity per user' rule), but we
+ // still send them for older clients / log clarity.
try {
const r = await fetch(
`/api/v1/syndicates/${encodeURIComponent(slug)}/join`,
@@ -558,8 +616,8 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- handle: handle.trim(),
- display_name: displayName.trim(),
+ handle: handleForSave,
+ display_name: handleForSave,
}),
},
);
@@ -585,7 +643,7 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
);
return { ok: false };
}
- }, [slug, handle, displayName]);
+ }, [slug, displayName, firstName, lastName]);
const onSubmitCode = useCallback(
async (e: React.FormEvent) => {
@@ -708,8 +766,8 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
const p = loadPending();
if (p && p.slug === slug) {
setDisplayName(p.displayName);
- setHandle(p.handle);
- setHandleTouched(true);
+ setFirstName(p.firstName);
+ setLastName(p.lastName);
}
}, [open, slug]);
@@ -809,37 +867,57 @@ export function JoinSyndicate({ slug, syndicateName }: JoinSyndicateProps) {
{safeT(t, "join.modal.title", "Join {pool_name}").replace("{pool_name}", syndicateName)}
- {safeT(t, "join.modal.body", "Pick a display name and handle for the leaderboard, then we'll send a one-time login code by WhatsApp or email.")}
+ {safeT(t, "join.modal.body", "Pick your @handle for the leaderboard, then we'll send a one-time login code by WhatsApp or email.")}