diff --git a/.gitignore b/.gitignore index dacf96bc..ffa0e6da 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ next-env.d.ts .vscode/ .idea/ .eslintcache + +# Local agent instructions (not shared) +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ad114513..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,253 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with this frontend repository. - -## MANDATORY: Read Before Writing Code - -Before writing ANY code, you MUST: -- **Follow existing patterns** — Look at how similar components/hooks/stores are already written in the codebase -- **Read backend `TYPES.md`** (`../backend-node/TYPES.md`) — Understand how API types flow from Zod → OpenAPI → frontend -- **Read backend `socket.types.ts`** (`../backend-node/src/realtime/socket.types.ts`) — Source of truth for all realtime event types -- **Read backend `docs/coding-patterns.md`** — Understand architecture rules that apply across both projects - -Always match existing patterns. When in doubt, look at how similar code is already written. - -## Commands - -```bash -# Development -npm run dev # Start Next.js dev server -npm run build # Production build -npm run start # Start production server -npm run typecheck # TypeScript check (tsc --noEmit) -npm run lint # ESLint - -# API Type Sync (backend must be running on localhost:8001) -npm run api:sync:local # Regenerate types from backend OpenAPI spec -npm run api:check # CI: verify types are up-to-date -``` - -## Architecture - -Next.js App Router frontend with feature-based organization. - -### Directory Structure - -``` -src/ -├── app/ # Next.js App Router pages and layouts -│ └── (app)/ # Main app layout group -├── features/ # Feature modules (components, hooks, logic) -│ ├── game/ # Core game UI and hooks -│ ├── possession/ # Possession match mode (game engine UI) -│ ├── home/ # Home screen -│ ├── play/ # Play/matchmaking -│ ├── tournaments/ # Events/tournaments -│ ├── profile/ # User profile -│ ├── leaderboard/ # Rankings -│ ├── dev/ # Dev tools (DevOverlay) -│ └── ... -├── stores/ # Zustand state stores -│ ├── auth.store.ts # Auth state -│ ├── realtimeMatch.store.ts # Realtime match state (from socket events) -│ ├── possessionMatch.store.ts # Local possession game state -│ └── gameSession.store.ts # Game session state -├── lib/ # Shared libraries -│ ├── api/ # API client (openapi-fetch, type-safe) -│ ├── realtime/ # Socket.IO client, handlers, types -│ ├── auth/ # Auth utilities -│ ├── queries/ # React Query hooks -│ └── utils/ # Shared utilities -├── components/ # Shared UI components (shadcn/ui based) -├── hooks/ # Shared React hooks -├── types/ # TypeScript types (including api.generated.ts) -└── styles/ # Global styles -``` - -### Key Patterns - -**Feature modules:** Each feature in `src/features/` is self-contained with its own components, hooks, and logic. Features should NOT import from other features directly — use shared stores or libs instead. - -**State management:** Zustand stores for global state. No Redux. Stores are in `src/stores/`. - -**Realtime events:** Socket.IO client in `src/lib/realtime/`. Event types mirror backend `socket.types.ts`. Handlers in `src/lib/realtime/socket-handlers.ts`. - -**API calls:** Type-safe API client using `openapi-fetch`. Types auto-generated from backend OpenAPI spec. Use React Query (`@tanstack/react-query`) for data fetching. - -**UI components:** shadcn/ui (Radix UI primitives + Tailwind). Shared components in `src/components/`. - -## Type Safety — STRICT - -- Strict TypeScript (`strict: true` in tsconfig) -- **No `any`** — use proper types or `unknown` with type guards -- API types auto-generated: `npm run api:sync:local` → `src/types/api.generated.ts` -- Socket event types in `src/lib/realtime/socket.types.ts` — must mirror backend `socket.types.ts` -- Always run `npm run typecheck` before considering code complete -- When backend types change, regenerate with `npm run api:sync:local` - -### Type Sync Flow - -``` -Backend Zod Schemas → OpenAPI Spec (/openapi.json) → openapi-typescript → src/types/api.generated.ts -Backend socket.types.ts ────── manually mirrored ────── src/lib/realtime/socket.types.ts -``` - -**After backend API changes:** Run `npm run api:sync:local` then `npm run typecheck`. -**After backend socket type changes:** Manually update `src/lib/realtime/socket.types.ts` to match. - -## Design System — Duolingo-Inspired - -Follow the established visual language consistently. - -### Colors — use tokens, NOT raw hex - -All colors are defined as Tailwind v4 design tokens in `src/styles/globals.css` -under the `:root` (HSL CSS variables) and `@theme inline` (Tailwind utility -classes) blocks. **Never write `bg-[#hex]` / `text-[#hex]` / `border-[#hex]/20` -in class strings** — use the tokens. - -```tsx -// ❌ DON'T — raw hex blocks the audit test and CodeRabbit reviews - - -// ✅ DO — semantic tokens, opacity modifiers work automatically - -``` - -**Brand palette — primary:** -| Token | Hex | Use | -|---|---|---| -| `brand-blue` | `#1645FF` | Score pill, navbar avatar, QUESTION pill | -| `brand-yellow` | `#FFE500` | RP pills, level XP bar, splash, COUNTDOWN tag | -| `brand-yellow-deep` | `#FCD200` | Slightly darker yellow accents | -| `brand-yellow-soft` | `#F8D34A` | Softer yellow tint | -| `brand-green` | `#38B60E` | PLAY AGAIN, correct answer, RP bar fill | -| `brand-green-deep` | `#2D950B` | `brand-green` hover/active state | -| `brand-green-light` | `#58CC02` | Bright lime, success accents (was `duo-lime`) | -| `brand-red` | `#FB3101` | DEFEAT heading, wrong answer text | -| `brand-red-soft` | `#FF4B4B` | Opponent ring, error states | -| `brand-red-light` | `#FF6B6B` | Lighter red accent | -| `brand-red-deep` | `#E04242` | Deeper red accent | -| `brand-cyan` | `#1CB0F6` | Info accent, settings icon, default link | -| `brand-cyan-deep` | `#1899D6` | Deeper cyan accent | - -**Brand palette — accents & neutrals:** -| Token | Hex | Use | -|---|---|---| -| `brand-orange` | `#FF9600` | Warm accents, warnings | -| `brand-orange-light` | `#FF8A3D` | Softer orange | -| `brand-purple` | `#CE82FF` | Special-mode accents | -| `brand-gold` | `#FFD700` | RP / leaderboard highlights | -| `brand-gold-deep` | `#B8860B` | Darker gold | -| `brand-slate` | `#56707A` | Muted text, secondary metadata | -| `brand-slate-deep` | `#3A4F56` | Darker slate | -| `brand-slate-light` | `#9CB6C2` | Light muted text on dark surfaces | - -**Surface palette:** -| Token | Hex | Use | -|---|---|---| -| `surface-page` | `#071013` | Deepest page background | -| `surface-page-alt` | `#0F1420` | Alternate page background | -| `surface-page-deep` | `#101820` | Deeper page variant | -| `surface-darkest` | `#0D1117` | Deepest standalone surface | -| `surface-card-deep` | `#0D1B21` | Chunky card bottom border | -| `surface-card-deeper` | `#0F1F26` | Deepest card surface | -| `surface-card` | `#1B2F36` | Card body | -| `surface-card-tint` | `#243B44` | Card tint, hover state | -| `surface-card-light` | `#2A4A55` | Slate-like card surface | -| `surface-deep` | `#131F24` | Overlay surface | -| `surface-input` | `#17222A` | Input field background | - -**Pre-existing semantic tokens** (still work as before): `bg-card`, `bg-background`, -`text-primary`, `text-foreground`, `border-border`, `text-muted-foreground`, -`text-destructive` — see `globals.css` for the full list. - -### Opacity - -HSL CSS vars compose with Tailwind's opacity modifier — `bg-brand-cyan/20`, -`border-brand-yellow/30`, `text-brand-red/50` all work without further config. - -### Adding a new color - -1. **First, check the existing palette.** 90% of new UI should map to an - existing token. If you find yourself typing `bg-[#…]`, pause and ask - whether `bg-brand-X` / `bg-surface-X` covers it. - -2. **Run `npm run colors:audit`** — if your hex appears in the report and is - used 5+ times across the codebase, it deserves a token. - -3. **Add the token** in `src/styles/globals.css`: - - Add an HSL value to `:root` with a comment explaining the use - (e.g. `--brand-amber: 45 100% 50%; /* #FFC107 — coin counter */`) - - Expose it via `@theme inline` (`--color-brand-amber: hsl(var(--brand-amber));`) - -4. **Register the mapping** in `scripts/audit-brand-colors.mjs` → - `KNOWN_TOKENS` map so the audit knows the hex → token translation. - -5. **Run `node scripts/migrate-brand-colors.mjs --write`** to migrate any - existing raw-hex usages of that color across the codebase. - -6. **Update this section** of `CLAUDE.md` with the new token row. - -### Verifying — the audit ratchet - -A regression test (`src/__tests__/no-hex-class-colors.test.ts`) fails CI if a -file outside the allowlist (`scripts/brand-colors-allowlist.json`) introduces -new hex classes, or if the allowlist has stale entries. As you migrate files -off raw hex, remove them from the allowlist — the test enforces it stays gone. - -```bash -npm run colors:audit # human-readable per-color/per-file report -npm run colors:check # CI-style: exit 1 on offenders / stale entries -npm run colors:update # rewrite allowlist from the current state -``` - -For the full migration guide, see `docs/BRAND_COLORS.md`. - -### When NOT to use a token - -- **Inline `style={{ backgroundColor: '#hex' }}` props** — these aren't - Tailwind classes and the audit ignores them. If you need dynamic colors - (e.g. a generated user avatar bg), inline-style is fine. -- **Complex shadow expressions** like `shadow-[0_4px_8px_rgba(0,0,0,0.4)]` - with rgba — leave as-is until we have a shadow-token system. -- **Genuine one-offs** used 1–2 times for a specific visual (e.g. a unique - gradient stop). Keep them on the allowlist if they don't deserve a global - token. - -### UI patterns -- Chunky 3D borders: `border-b-4 border-surface-card-deep` on cards, buttons, badges -- `font-fun` class for game typography (Nunito) -- Rounded corners: `rounded-2xl` or `rounded-3xl` for game elements - -**Animations:** Use `motion/react` (NOT `framer-motion`). Example: -```tsx -import { motion } from 'motion/react'; - - -``` - -## Game Stage Router Pattern - -When building game UI that switches between stages (matchmaking → draft → playing → results): -- **Stage Router (container):** Reads store state, coordinates transitions, selects which screen to render -- **Stage Transitions Hook:** Encapsulates side effects (socket events, stage changes) in a dedicated hook -- **Screen Components (presentational):** Accept typed props only, no store access, no socket logic - -Keep routing logic in one place, not inside each screen. - -## PR Checklist - -Before submitting code, verify: -- [ ] `npm run typecheck` passes -- [ ] `npm run lint` passes -- [ ] No `any` types -- [ ] Socket types match backend `socket.types.ts` -- [ ] Components follow Duolingo design patterns (colors, borders, font-fun) -- [ ] Animations use `motion/react` (not `framer-motion`) -- [ ] Feature code is self-contained in `src/features/` -- [ ] Shared state goes through Zustand stores diff --git a/public/sounds/correct_answer.mp3 b/public/sounds/correct_answer.mp3 new file mode 100644 index 00000000..744e5490 Binary files /dev/null and b/public/sounds/correct_answer.mp3 differ diff --git a/public/sounds/wrong_answer.mp3 b/public/sounds/wrong_answer.mp3 new file mode 100644 index 00000000..e6125347 Binary files /dev/null and b/public/sounds/wrong_answer.mp3 differ diff --git a/src/app/(app)/daily/challenges/[challengeId]/page.tsx b/src/app/(app)/daily/challenges/[challengeId]/page.tsx index 7fb39212..3fc51bf2 100644 --- a/src/app/(app)/daily/challenges/[challengeId]/page.tsx +++ b/src/app/(app)/daily/challenges/[challengeId]/page.tsx @@ -13,13 +13,14 @@ import { CareerPathGame } from "@/features/daily/CareerPathGame"; import { HighLowGame } from "@/features/daily/HighLowGame"; import { FootballLogicGame } from "@/features/daily/FootballLogicGame"; import { QuitGameDialog } from "@/features/daily/QuitGameDialog"; +import { DailyChallengeIntro } from "@/features/daily/components/DailyChallengeIntro"; +import { consumeDailyChallengeSession } from "@/features/daily/dailyChallengeSessionPrefetch"; import { DAILY_CHALLENGE_VISUALS } from "@/lib/domain/dailyChallengeVisuals"; import { useCompleteDailyChallenge } from "@/lib/queries/dailyChallenges.queries"; import { queryKeys } from "@/lib/queries/queryKeys"; import { usePlayer } from "@/contexts/PlayerContext"; import type { DailyChallengeSession, DailyChallengeType } from "@/lib/domain/dailyChallenge"; import { trackDailyChallengeCompleted, trackDailyChallengeStarted, trackDailyChallengeQuit } from "@/lib/analytics/game-events"; -import { ApiError } from "@/lib/api/api"; import { createDailyChallengeSession } from "@/lib/repositories/dailyChallenges.repo"; import { toDailyChallengeSession } from "@/lib/mappers/dailyChallenge.mapper"; import { useLocale } from "@/contexts/LocaleContext"; @@ -29,45 +30,20 @@ function isDailyChallengeType(value: string): value is DailyChallengeType { return value in DAILY_CHALLENGE_VISUALS; } -function getSessionErrorMessage(error: unknown): string { - if (error instanceof ApiError && error.data && typeof error.data === "object") { - const data = error.data as { - code?: string; - message?: string; - details?: { - needed?: number; - available?: number; - }; - }; - - if (data.code === "DAILY_CHALLENGE_CONTENT_UNAVAILABLE") { - const needed = data.details?.needed; - const available = data.details?.available; - if (typeof needed === "number" && typeof available === "number") { - return `This challenge needs ${needed} published questions, but only ${available} are available. Lower the question count in CMS or publish more questions.`; - } - return "This challenge does not have enough published questions yet. Update the CMS config or publish more questions."; - } - - if (data.message) { - return data.message; - } - } - - return "Could not start this daily challenge. Check the CMS setup and try again."; -} - export default function ChallengePage() { const params = useParams(); const router = useRouter(); const queryClient = useQueryClient(); const { addXP } = usePlayer(); - const { locale } = useLocale(); + const { locale, t } = useLocale(); const [showBrowserBackDialog, setShowBrowserBackDialog] = useState(false); const [session, setSession] = useState(); const [sessionError, setSessionError] = useState(null); const [isSessionLoading, setIsSessionLoading] = useState(false); const [sessionAttempt, setSessionAttempt] = useState(0); + // Gate the game behind a "get ready" intro; the game (and its timer) only + // mounts once the intro has played. Reset on every new session attempt. + const [introDone, setIntroDone] = useState(false); const guardPushed = useRef(false); const completeOnceRef = useRef(false); @@ -77,7 +53,10 @@ export default function ChallengePage() { const invalidateAfterComplete = useCallback(async () => { await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.dailyChallenges.list() }), + // `.all` (not `.list()`, which defaults to the "en" locale key) so the + // refetch hits whatever locale the hub is actually showing — otherwise a + // Georgian user's list never refreshes and can show a stale/empty state. + queryClient.invalidateQueries({ queryKey: queryKeys.dailyChallenges.all }), queryClient.invalidateQueries({ queryKey: queryKeys.store.wallet() }), ]); }, [queryClient]); @@ -151,10 +130,18 @@ export default function ChallengePage() { setIsSessionLoading(true); setSessionError(null); setSession(undefined); + setIntroDone(false); }); - createDailyChallengeSession(challengeType, locale) - .then(toDailyChallengeSession) + // Reuse the session the hub started on tap-down if it's still fresh; that + // POST already overlapped the navigation, so this is usually resolved by now. + // Falls back to creating one when there's no fresh prefetch (deep link, slow + // network, stale TTL). + const prefetched = consumeDailyChallengeSession(challengeType, locale, Date.now()); + const sessionPromise = + prefetched ?? createDailyChallengeSession(challengeType, locale).then(toDailyChallengeSession); + + sessionPromise .then((nextSession) => { if (cancelled) return; setSession(nextSession); @@ -235,30 +222,28 @@ export default function ChallengePage() { if (sessionError || sessionTypeMismatch) { return ( -
-
-

Challenge unavailable

-

- {sessionTypeMismatch - ? `Received a ${session?.challengeType} session while opening ${challengeType}. Refresh and try again.` - : getSessionErrorMessage(sessionError)} +

+
+

+ {t("dailyGames.unavailableTitle")} +

+

+ {t("dailyGames.unavailableMessage")}

-
+
@@ -274,6 +259,12 @@ export default function ChallengePage() { ); } + // Play the "get ready" intro first; the game (and its timer) only mounts + // after it finishes, so the countdown never starts before the player is ready. + if (!introDone) { + return setIntroDone(true)} />; + } + return ( <> {gameContent} diff --git a/src/app/(app)/daily/challenges/page.tsx b/src/app/(app)/daily/challenges/page.tsx index a56bbcf3..2b999043 100644 --- a/src/app/(app)/daily/challenges/page.tsx +++ b/src/app/(app)/daily/challenges/page.tsx @@ -1,39 +1,47 @@ "use client"; import Image from "next/image"; -import { useEffect, useMemo, useState, type MouseEvent } from "react"; +import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react"; import { useRouter } from "next/navigation"; import { CheckCircle2, RotateCcw } from "lucide-react"; import { motion } from "motion/react"; import { toast } from "sonner"; import { useDailyChallenges, useResetDailyChallengeDev } from "@/lib/queries/dailyChallenges.queries"; import { queryKeys } from "@/lib/queries/queryKeys"; -import type { DailyChallengeSummary } from "@/lib/domain/dailyChallenge"; +import type { DailyChallengeSummary, DailyChallengeType } from "@/lib/domain/dailyChallenge"; import { useAuthStore } from "@/stores/auth.store"; import { useQueryClient } from "@tanstack/react-query"; +import { useLocale } from "@/contexts/LocaleContext"; +import { prefetchDailyChallengeSession } from "@/features/daily/dailyChallengeSessionPrefetch"; -function getTimeUntilUtcReset() { - const now = new Date(); - const tomorrow = new Date(now); - tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); - tomorrow.setUTCHours(0, 0, 0, 0); - const remainingMs = tomorrow.getTime() - now.getTime(); - const hours = Math.floor(remainingMs / (1000 * 60 * 60)); - const minutes = Math.floor((remainingMs % (1000 * 60 * 60)) / (1000 * 60)); - return `${hours}h ${minutes}m`; +// Challenges reset at 00:00 UTC. Show that moment as a wall-clock time in the +// viewer's own timezone (auto-detected by Intl) so a Georgia user sees 04:00 +// and an EST user sees 7:00 PM — no confusing "UTC" label. Georgian uses 24h +// (EU/Georgia convention, e.g. 04:00 / 20:00); English keeps 12h AM/PM. +function getLocalResetTime(locale: string) { + const reset = new Date(); + reset.setUTCHours(24, 0, 0, 0); // next 00:00 UTC + return new Intl.DateTimeFormat(locale === "ka" ? "ka-GE" : "en-US", { + hour: "2-digit", + minute: "2-digit", + hourCycle: locale === "ka" ? "h23" : "h12", + }).format(reset); } function ChallengeCard({ challenge, index, onClick, + onPrefetch, showDevReset, }: { challenge: DailyChallengeSummary; index: number; onClick: () => void; + onPrefetch: () => void; showDevReset: boolean; }) { + const { t } = useLocale(); const queryClient = useQueryClient(); const resetMutation = useResetDailyChallengeDev(challenge.challengeType); const disabled = !challenge.availableToday; @@ -45,9 +53,9 @@ function ChallengeCard({ try { await resetMutation.mutateAsync(); await queryClient.invalidateQueries({ queryKey: queryKeys.dailyChallenges.all }); - toast.success(`${challenge.title} reset for today`); + toast.success(t('dailyGames.hubResetSuccess', { title: challenge.title })); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to reset daily challenge"; + const message = error instanceof Error ? error.message : t('dailyGames.hubResetError'); toast.error(message); } }; @@ -57,47 +65,62 @@ function ChallengeCard({ initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.35, delay: 0.05 + index * 0.04, ease: "easeOut" }} - className="relative" + className="relative flex h-full" > @@ -110,7 +133,7 @@ function ChallengeCard({ className="absolute right-1.5 top-1.5 inline-flex h-4 items-center gap-0.5 rounded-md bg-black/60 px-1.5 text-[7px] font-black uppercase tracking-wide text-white hover:bg-black/80 disabled:opacity-50 md:right-2 md:top-2 md:h-auto md:gap-1 md:rounded-lg md:px-2 md:py-1 md:text-[10px]" > - {resetMutation.isPending ? "…" : "Reset"} + {resetMutation.isPending ? "…" : t('dailyGames.hubResetButton')} )} @@ -118,18 +141,40 @@ function ChallengeCard({ } export default function DailyChallengesPage() { + const { t, locale } = useLocale(); const router = useRouter(); const authUser = useAuthStore((state) => state.user); const { data: challenges = [], isLoading } = useDailyChallenges(); - const [timeUntilReset, setTimeUntilReset] = useState(() => getTimeUntilUtcReset()); + // Computed client-side only (depends on the browser's timezone), so it stays + // empty on the server and first paint to avoid a hydration mismatch. + const [localResetTime, setLocalResetTime] = useState(""); const canUseDevReset = authUser?.role === "admin"; useEffect(() => { - const intervalId = window.setInterval(() => { - setTimeUntilReset(getTimeUntilUtcReset()); - }, 60_000); - return () => window.clearInterval(intervalId); - }, []); + setLocalResetTime(getLocalResetTime(locale)); + }, [locale]); + + // Prefetch the page bundle for AVAILABLE challenges only (completed/locked + // ones can't be opened, so prefetching them is wasted). The session POST is + // what we overlap on tap-down. + useEffect(() => { + for (const challenge of challenges) { + if (!challenge.availableToday) continue; + router.prefetch(`/daily/challenges/${challenge.challengeType}`); + } + }, [challenges, router]); + + // Start (or reuse) the session the moment the user presses a card — the POST + // round-trip then overlaps the navigation + intro, so the game's ready by the + // time the intro finishes. Available cards only; completed ones aren't tapped. + const handlePrefetchSession = useCallback( + (challengeType: DailyChallengeType) => { + void prefetchDailyChallengeSession(challengeType, locale, Date.now()).catch(() => { + // Swallow — the challenge page will surface and retry any real failure. + }); + }, + [locale], + ); const completedCount = useMemo( () => challenges.filter((c) => c.completedToday).length, @@ -159,39 +204,41 @@ export default function DailyChallengesPage() { {/* Header */}
-

- Daily Challenges +

+ {t('dailyGames.hubTitle')}

-

- UTC Reset in {timeUntilReset} -

+ {localResetTime ? ( +

+ {t('dailyGames.hubResetAt', { time: localResetTime })} +

+ ) : null}
-
-
-

- Today's Progress +

+ {/* Label, bar, and count are one left-aligned column whose width is + set by the (widest) label — so the bar and the count line up to + the label's edges in any locale (EN or the wider Georgian). */} +
+

+ {t('dailyGames.hubTodaysProgress')}

-
+
-

+

{completedCount}/{challenges.length}

-
-

- Coins Earned -

-

- {earnedCoins} +

+

+ {t('dailyGames.hubCoinsEarned')}

-

- of {totalCoins} +

+ {earnedCoins}/{totalCoins}

@@ -200,7 +247,7 @@ export default function DailyChallengesPage() { {/* Grid of challenge cards */} {isLoading ? (
- Loading today's challenge lineup... + {t('dailyGames.hubLoading')}
) : (
@@ -210,6 +257,7 @@ export default function DailyChallengesPage() { challenge={challenge} index={index} onClick={() => router.push(`/daily/challenges/${challenge.challengeType}`)} + onPrefetch={() => handlePrefetchSession(challenge.challengeType)} showDevReset={canUseDevReset} /> ))} @@ -218,8 +266,8 @@ export default function DailyChallengesPage() { {!isLoading && challenges.length > 0 && (
-

- Today's Progress +

+ {t('dailyGames.hubTodaysProgress')}

-

+

{completedCount}/{challenges.length}

@@ -238,7 +286,7 @@ export default function DailyChallengesPage() {

- Play For + {t('dailyGames.hubPlayFor')}

{totalPlayCost} @@ -247,7 +295,7 @@ export default function DailyChallengesPage() {

- Earn + {t('dailyGames.hubEarn')}

{totalXp} XP diff --git a/src/app/(app)/friend/room/[code]/page.tsx b/src/app/(app)/friend/room/[code]/page.tsx index eea04ef4..a1545304 100644 --- a/src/app/(app)/friend/room/[code]/page.tsx +++ b/src/app/(app)/friend/room/[code]/page.tsx @@ -1,20 +1,18 @@ "use client"; import { FriendLobbyScreen } from "@/features/friend/components/FriendLobbyScreen"; -import { useSearchParams, useParams } from "next/navigation"; +import { useParams } from "next/navigation"; import { useLocale } from "@/contexts/LocaleContext"; +import { extractFriendInviteCode } from "@/lib/friend/inviteCode"; export default function FriendRoomPage() { const params = useParams(); - const searchParams = useSearchParams(); const { t } = useLocale(); - // Strip zero-width/invisible characters and whitespace that social apps - // (Facebook/Instagram) inject into shared links, then keep only valid - // room-code characters so a copy-pasted invite still resolves instead of 404ing. - // useParams() returns the already-decoded segment, so we sanitize directly. - const code = ((params?.code as string) ?? "").replace(/[^A-Za-z0-9]/g, "").toUpperCase(); - const isHost = searchParams?.get("isHost") === "true"; + const rawCode = ((params?.code as string) ?? "").trim(); + const isNewRoomRoute = rawCode.toLowerCase() === "new"; + const code = isNewRoomRoute ? "new" : extractFriendInviteCode(rawCode); + const isHost = isNewRoomRoute; if (!code) return
{t('inviteCode.invalid')}
; diff --git a/src/app/(app)/play/page.tsx b/src/app/(app)/play/page.tsx index 1b775f63..615e3561 100644 --- a/src/app/(app)/play/page.tsx +++ b/src/app/(app)/play/page.tsx @@ -1,6 +1,6 @@ "use client"; -// import { useState } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { ModeSelectionScreen } from "@/features/play/ModeSelectionScreen"; import { useGameSessionStore } from "@/stores/gameSession.store"; @@ -11,7 +11,7 @@ import { useCategoriesList } from "@/lib/queries/categories.queries"; import { useFeaturedCategories } from "@/lib/queries/featuredCategories.queries"; import { useMatchStatsSummary } from "@/lib/queries/stats.queries"; import { useRankedProfile } from "@/lib/queries/ranked.queries"; -import { useStoreWallet } from "@/lib/queries/store.queries"; +import { useStoreWallet, getStoreWalletQuery } from "@/lib/queries/store.queries"; import { useRealtimeMatchStore } from "@/stores/realtimeMatch.store"; import { useRankedMatchmakingStore } from "@/stores/rankedMatchmaking.store"; import type { CategorySummary, GameQuestion } from "@/lib/domain"; @@ -22,6 +22,9 @@ import { trackModeSelected } from "@/lib/analytics/game-events"; import { shuffleArray } from "@/lib/utils"; import { useLocale } from "@/contexts/LocaleContext"; +// Ranked entry costs 1 ticket — mirrors ModeConfirmModal's CONFIG.ranked.entryCost. +const RANKED_TICKET_COST = 1; + export default function PlayPage() { const router = useRouter(); const { t } = useLocale(); @@ -41,6 +44,13 @@ export default function PlayPage() { const defaultCategory: CategorySummary | undefined = featuredData?.items[0]?.category ?? categoriesData?.items[0]; + // Refresh the ticket balance whenever the Play screen opens so the ranked + // confirm modal shows the correct insufficient-tickets state up front (the + // cached wallet seeded from localStorage can otherwise be stale). + useEffect(() => { + void queryClient.invalidateQueries({ queryKey: queryKeys.store.wallet() }); + }, [queryClient]); + const fetchQuestions = async (categoryId?: string) => { const filters: ListQuestionsQuery = { category_id: categoryId, @@ -68,6 +78,24 @@ export default function PlayPage() { // For ranked mode, start matchmaking without pre-fetching questions // Questions will be fetched after category blocking if (params.mode === "ranked") { + // Gate on the LIVE ticket balance before entering the search screen — the + // cached wallet (seeded from localStorage) can be stale and let a 0-ticket + // player into matchmaking, where the server then rejects the queue join and + // strands them on the searching map. Refetch fresh; if they can't afford it, + // bounce to the store instead of starting. + let liveTickets = storeWallet?.tickets ?? 0; + try { + const fresh = await queryClient.fetchQuery(getStoreWalletQuery()); + liveTickets = fresh.tickets; + } catch { + // Network hiccup — fall back to the cached value rather than hard-blocking. + } + if (liveTickets < RANKED_TICKET_COST) { + toast.error(t("modeConfirm.notEnoughTickets")); + router.push("/store"); + return; + } + startSession({ ...params, matchType: "ranked", diff --git a/src/app/(app)/settings/__tests__/page.test.tsx b/src/app/(app)/settings/__tests__/page.test.tsx new file mode 100644 index 00000000..efe1fbf8 --- /dev/null +++ b/src/app/(app)/settings/__tests__/page.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SettingsPage from '../page'; + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})); + +vi.mock('@/features/settings/SettingsScreen', () => ({ + SettingsScreen: ({ onBack }: { onBack: () => void }) => ( + + ), +})); + +describe('SettingsPage', () => { + beforeEach(() => { + mocks.push.mockClear(); + }); + + it('returns directly to the app play route', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(mocks.push).toHaveBeenCalledWith('/play'); + }); +}); diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 334a7286..7458ad77 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -6,5 +6,5 @@ import { SettingsScreen } from "@/features/settings/SettingsScreen"; export default function SettingsPage() { const router = useRouter(); - return router.push("/")} />; + return router.push("/play")} />; } diff --git a/src/app/(fullscreen)/dev/animations/page.tsx b/src/app/(fullscreen)/dev/animations/page.tsx index 6ac72a54..1ccf3867 100644 --- a/src/app/(fullscreen)/dev/animations/page.tsx +++ b/src/app/(fullscreen)/dev/animations/page.tsx @@ -37,7 +37,7 @@ const SELF_ID = 'dev-self'; const OPP_ID = 'dev-opp'; const TOTAL_QUESTIONS = 12; const POINTS_PER_BAR = 10; -const MAX_BARS = 12; +const MAX_BARS = 20; const DEV_PUT_ORDER_OPPONENT_DELAY_MS = 900; const DEV_PUT_ORDER_ROUND_RESULT_DELAY_MS = 2100; const DEV_SPECIAL_ROUND_RESULT_DELAY_MS = 1600; @@ -287,9 +287,9 @@ function makeRoundResult( outcome: Outcome, scores: { meTotal: number; oppTotal: number }, customPoints: { me: number; opp: number }, - // Dev-only 2× speed-streak preview: when true, my swing is doubled this round - // and I hold the streak afterwards (shows the 2× badge + bar jump together). - speedStreakMe = false + // Dev-only 2× speed-streak preview: when set, that seat's possession swing is + // doubled this round while base points stay unchanged, matching production. + boostedSeat: 1 | 2 | null = null ): MatchRoundResultPayload { const sample = SAMPLE_QUESTIONS[qIndex % SAMPLE_QUESTIONS.length]; @@ -298,9 +298,11 @@ function makeRoundResult( // Points earned only count when that side was correct. Custom sliders let // you preview big-vs-small bar battles (e.g. +80 vs +10 → 8 bars vs 1). - const mePoints = (meCorrect ? customPoints.me : 0) * (speedStreakMe ? 2 : 1); - const oppPoints = oppCorrect ? customPoints.opp : 0; - const possessionDelta = mePoints - oppPoints; + const meBasePoints = meCorrect ? customPoints.me : 0; + const oppBasePoints = oppCorrect ? customPoints.opp : 0; + const mePossessionPoints = meBasePoints * (boostedSeat === 1 ? 2 : 1); + const oppPossessionPoints = oppBasePoints * (boostedSeat === 2 ? 2 : 1); + const possessionDelta = mePossessionPoints - oppPossessionPoints; const goalScoredBySeat: 1 | 2 | null = outcome === 'goal-me' ? 1 : outcome === 'goal-opp' ? 2 : null; @@ -315,18 +317,20 @@ function makeRoundResult( }, players: { [SELF_ID]: { - totalPoints: scores.meTotal + mePoints, - pointsEarned: mePoints, + totalPoints: scores.meTotal + meBasePoints, + pointsEarned: meBasePoints, + possessionPointsEarned: mePossessionPoints, isCorrect: meCorrect, - timeMs: speedStreakMe ? 1800 : 3000, + timeMs: boostedSeat === 1 ? 1800 : 3000, selectedIndex: meCorrect ? sample.correctIndex : (sample.correctIndex + 1) % 4, submittedOrderIds: [], }, [OPP_ID]: { - totalPoints: scores.oppTotal + oppPoints, - pointsEarned: oppPoints, + totalPoints: scores.oppTotal + oppBasePoints, + pointsEarned: oppBasePoints, + possessionPointsEarned: oppPossessionPoints, isCorrect: oppCorrect, - timeMs: 4200, + timeMs: boostedSeat === 2 ? 1800 : 4200, selectedIndex: oppCorrect ? sample.correctIndex : (sample.correctIndex + 2) % 4, submittedOrderIds: [], }, @@ -339,8 +343,8 @@ function makeRoundResult( penaltyOutcome: null, // Hold the streak afterwards (and mark it boosted this round) so the // 2× badge stays lit until cleared by a goal/slower/wrong round. - speedStreakHolderSeat: speedStreakMe && !goalScoredBySeat ? 1 : null, - speedStreakBoostedSeat: speedStreakMe ? 1 : null, + speedStreakHolderSeat: boostedSeat && !goalScoredBySeat ? boostedSeat : null, + speedStreakBoostedSeat: boostedSeat, }, }; } @@ -434,6 +438,10 @@ type PenaltyKickOptions = { answerAckDelayMs?: number; opponentAnsweredDelayMs?: number; roundResultDelayMs?: number; + // When true, DON'T advance to the next shooter after the round result. Used for + // the FINAL/deciding kick so the goal/save shot + splash play out fully instead + // of being cut short by the next-shooter state change (the reported bug). + holdOnResult?: boolean; }; function defaultPenaltyPoints(shooterSeat: 1 | 2, outcome: 'goal' | 'saved'): { me: number; opp: number } { @@ -836,6 +844,7 @@ function DevAnimationsContent() { half: 1, goals: goalsRef.current, possessionDiff: newDiff, + speedStreakHolderSeat: result.deltas?.speedStreakHolderSeat ?? null, }) ); }, moveDelayMs) @@ -1457,7 +1466,7 @@ function DevAnimationsContent() { if (result.deltas?.goalScoredBySeat === 2) goalsRef.current.seat2 += 1; } - function fireOutcome(outcome: Outcome, speedStreakMe = false) { + function fireOutcome(outcome: Outcome, boostedSeat: 1 | 2 | null = null) { // Mobile: auto-dismiss the controls drawer so the animation has the // full viewport. Desktop is unaffected (panel is lg:translate-x-0). setMobilePanelOpen(false); @@ -1466,7 +1475,7 @@ function DevAnimationsContent() { const s = store(); const q = s.match?.currentQuestion; if (!q) return; - const result = makeRoundResult(q.qIndex, outcome, scoreRef.current, { me: myPoints, opp: oppPoints }, speedStreakMe); + const result = makeRoundResult(q.qIndex, outcome, scoreRef.current, { me: myPoints, opp: oppPoints }, boostedSeat); const me = result.players[SELF_ID]; const opp = result.players[OPP_ID]; if (!me || !opp) return; @@ -1526,24 +1535,25 @@ function DevAnimationsContent() { // Dev demo for the 2× boost flight: turn the badge on first (so it's visible // in the HUD), then fire a boosted round — the +N flight detours through the // now-visible badge and doubles before flying to the bar. - function fireBoostDemo() { + function fireBoostDemo(side: 'player' | 'opponent' = 'player') { const s = store(); const q = s.match?.currentQuestion; if (!q) return; + const holderSeat = side === 'player' ? 1 : 2; // Set the live holder in match state (drives the sticky badge) AND a - // round result that flips holder null→me (triggers the badge fly-in flight). + // round result that flips holder null→seat (triggers the badge fly-in flight). stateVersion.current += 1; s.setMatchState(makeMatchState('NORMAL_PLAY', { stateVersion: stateVersion.current, possessionDiff: possessionDiffRef.current, - speedStreakHolderSeat: 1, + speedStreakHolderSeat: holderSeat, })); s.setRoundResult({ - ...makeRoundResult(q.qIndex, 'me-correct', scoreRef.current, { me: 0, opp: 0 }, true), + ...makeRoundResult(q.qIndex, 'me-correct', scoreRef.current, { me: 0, opp: 0 }, holderSeat), players: {}, }); pendingTimers.current.push( - window.setTimeout(() => fireOutcome('both-correct', true), 1100), + window.setTimeout(() => fireOutcome('both-correct', holderSeat), 1100), ); } @@ -1913,6 +1923,7 @@ function DevAnimationsContent() { const opponentAnsweredDelayMs = options.opponentAnsweredDelayMs ?? 3500; const roundResultDelayMs = options.roundResultDelayMs ?? DEV_PENALTY_ROUND_RESULT_DELAY_MS; const emitOpponentAnswered = options.emitOpponentAnswered ?? false; + const holdOnResult = options.holdOnResult ?? false; const nextShooterSeat = nextSeat(shooterSeat); stateVersion.current += 1; @@ -1928,13 +1939,20 @@ function DevAnimationsContent() { }) ); + // For kicks after the first, keep the previous round's result on the store + // so the next penalty question BUFFERS as pendingQuestion (real play does + // this) — that's what lets the "Penalty N" round-intro overlay show before + // promotion. The real promote-gate clears lastRoundResult on promotion. + const isFirstKick = kickIndex === 0; useRealtimeMatchStore.setState((prev) => prev.match ? { ...prev, match: { ...prev.match, - lastRoundResult: null, + ...(isFirstKick + ? { lastRoundResult: null, currentQuestionPhase: 'reveal' as const } + : {}), answerAck: null, countdownGuessAck: null, cluesGuessAck: null, @@ -1942,7 +1960,6 @@ function DevAnimationsContent() { opponentSelectedIndex: null, opponentRecentPoints: 0, opponentAnsweredCorrectly: null, - currentQuestionPhase: 'reveal', }, } : prev @@ -2003,6 +2020,10 @@ function DevAnimationsContent() { } scoreRef.current.meTotal = me.totalPoints; scoreRef.current.oppTotal = opp.totalPoints; + // Deciding kick: DON'T advance to the next shooter — that state change + // is what cuts the goal/save shot + splash short. Hold on the result so + // the animation plays out fully. + if (holdOnResult) return; stateVersion.current += 1; s.setMatchState( makeMatchState('PENALTY_SHOOTOUT', { @@ -2078,6 +2099,123 @@ function DevAnimationsContent() { }); } + // Penalty match-end simulator. Jumps STRAIGHT to the deciding kick (seeds the + // scoreboard at 2-0 so we don't sit through a whole shootout), takes ONE + // deciding goal, queues final_results, and lets the real match screen show the + // won/lost overlay after the penalty splash completes. + function simulatePenaltyMatchEnd(playerWon: boolean) { + setMobilePanelOpen(false); + pendingTimers.current.forEach((t) => window.clearTimeout(t)); + pendingTimers.current = []; + + stateVersion.current = 0; + scoreRef.current = { meTotal: 70, oppTotal: 70 }; // both on 70 like the report + goalsRef.current = { seat1: 6, seat2: 6 }; + // Seed near-deciding: the winning side already has 2, loser 0, and the + // deciding kick makes it 3-0. (decidingSeat scores; seat1 = me.) + const decidingSeat: 1 | 2 = playerWon ? 1 : 2; + penaltyGoalsRef.current = decidingSeat === 1 ? { seat1: 2, seat2: 0 } : { seat1: 0, seat2: 2 }; + penaltyKickIndexRef.current = 4; // we're deep in the shootout + possessionDiffRef.current = 0; + + const s = store(); + s.reset(); + s.setSelfUserId(SELF_ID); + s.setMatchStart(makeStartPayload()); + useRealtimeMatchStore.setState((prev) => + prev.match ? { ...prev, match: { ...prev.match, countdownEndsAt: null } } : prev + ); + + stateVersion.current += 1; + s.setMatchState( + makeMatchState('PENALTY_SHOOTOUT', { + stateVersion: stateVersion.current, + half: 2, + goals: goalsRef.current, + penaltyGoals: penaltyGoalsRef.current, + phaseKind: 'penalty', + phaseRound: penaltyPhaseRoundForKickIndex(penaltyKickIndexRef.current), + shooterSeat: decidingSeat, + }) + ); + setRemountKey((k) => k + 1); + + const finalPenaltyGoals = { + seat1: penaltyGoalsRef.current.seat1 + (decidingSeat === 1 ? 1 : 0), + seat2: penaltyGoalsRef.current.seat2 + (decidingSeat === 2 ? 1 : 0), + }; + + const emitFinalResults = () => { + stateVersion.current += 1; + s.setMatchState( + makeMatchState('COMPLETED', { + stateVersion: stateVersion.current, + half: 2, + goals: goalsRef.current, + penaltyGoals: finalPenaltyGoals, + phaseKind: 'penalty', + }) + ); + s.setFinalResults({ + matchId: MATCH_ID, + // Keep this aligned with makeStartPayload(). Downgrading to + // friendly_possession mid-animation makes the bar battle switch from + // small avatar-anchored ranked bars to the large classic layout. + variant: 'ranked_sim', + winnerId: playerWon ? SELF_ID : OPP_ID, + durationMs: 600_000, + resultVersion: 1, + winnerDecisionMethod: 'penalty_goals', + players: { + [SELF_ID]: { + totalPoints: scoreRef.current.meTotal, + correctAnswers: 9, + avgTimeMs: 5200, + goals: goalsRef.current.seat1, + penaltyGoals: finalPenaltyGoals.seat1, + }, + [OPP_ID]: { + totalPoints: scoreRef.current.oppTotal, + correctAnswers: 9, + avgTimeMs: 5400, + goals: goalsRef.current.seat2, + penaltyGoals: finalPenaltyGoals.seat2, + }, + }, + }); + }; + + // Take the deciding kick after a short beat (the shootout is already set up). + const decidingDelay = 600; + const t0 = performance.now(); + const log = (label: string) => + console.log(`[penalty-end] +${Math.round(performance.now() - t0)}ms — ${label}`); + pendingTimers.current.push( + window.setTimeout(() => { + log('deciding kick taken'); + // holdOnResult: keep the scene on the deciding shot so the goal/save + // animation + splash fully play instead of being cut by a next-shooter state. + takePenaltyKick(decidingSeat, 'goal', { resetTimers: false, holdOnResult: true }); + }, decidingDelay) + ); + + // When the round_result lands (shot starts resolving): + const roundResultAt = decidingDelay + DEV_PENALTY_ROUND_RESULT_DELAY_MS; + + pendingTimers.current.push(window.setTimeout(() => log('round_result lands (shot resolving)'), roundResultAt)); + + // final_results may arrive while the deciding kick is still playing. + // The real match screen now waits for PenaltySplash's animation-complete + // callback before showing the won/lost overlay, so the dev sim should not + // mount its own timer-driven overlay here. + pendingTimers.current.push( + window.setTimeout(() => { + log('final_results queued; real overlay waits for splash completion'); + emitFinalResults(); + }, roundResultAt + 50) + ); + } + return (
{/* Stage — main area with the real match screen */} @@ -2184,7 +2322,7 @@ function DevAnimationsContent() { label="Opp" suffix={`(${pointsToBars(oppPoints)} bars)`} />

- 10 pts = 1 bar (cap 12). Only counts if that side was correct. + 10 pts = 1 bar (boosted cap 20). Only counts if that side was correct.

+ ))} + +
+ +
+ +
+ {isLoading || categories.length === 0 ? ( +
+ Loading categories… +
+ ) : variant === 'prematch' ? ( + {}} + onBanCategory={handleBan} + /> + ) : ( + + )} +
+
+ ); +} + +const DEMO_PLAYER = { + id: 'demo-player', + username: 'YOU', + avatar: '/assets/avatars/default.png', + countryCode: 'ge', + rankPoints: 1240, +} as const; + +const DEMO_OPPONENT = { + id: 'demo-opponent', + username: 'BOT', + avatar: '/assets/avatars/default.png', + countryCode: 'us', + rankPoints: 1180, +} as const; diff --git a/src/app/(fullscreen)/dev/penalties/page.tsx b/src/app/(fullscreen)/dev/penalties/page.tsx new file mode 100644 index 00000000..b05f056b --- /dev/null +++ b/src/app/(fullscreen)/dev/penalties/page.tsx @@ -0,0 +1,98 @@ +'use client'; + +/** + * Dev-only route: a REAL playable AI penalty match. Unlike `dev/animations` + * (which scripts a stub socket), this connects to the real backend, boots a + * quick AI match, and asks the server to skip straight into the penalty + * category-ban phase — so the user plays the exact production penalty flow + * (ban → shootout) against the AI with the same components, animations, and + * delays. No normal open-play question is ever shown (the server suppresses + * question 0 via `dev:quick_match { skipTo: 'penalty_ban' }`). + * + * Guarded by NODE_ENV; backend dev handlers also no-op in production. + */ + +import { useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { getSocket } from '@/lib/realtime/socket-client'; +import { useRealtimeConnection } from '@/lib/realtime/useRealtimeConnection'; +import { useRealtimeMatchStore } from '@/stores/realtimeMatch.store'; +import { useAuthStore } from '@/stores/auth.store'; +import { resolveAvatarUrl } from '@/lib/avatars'; +import { RealtimePossessionMatchScreen } from '@/features/possession/RealtimePossessionMatchScreen'; + +function DevPenaltiesContent() { + const router = useRouter(); + const authUser = useAuthStore((s) => s.user); + const selfUserId = authUser?.id ?? null; + + // Connect the real authed socket + register handlers (same as the app shell). + useRealtimeConnection({ enabled: Boolean(selfUserId), selfUserId }); + + const match = useRealtimeMatchStore((s) => s.match); + const matchId = match?.matchId ?? null; + const quickMatchSentRef = useRef(false); + + // Boot a real AI match straight into the penalty ban phase. Fired once. + useEffect(() => { + if (!selfUserId || quickMatchSentRef.current) return; + quickMatchSentRef.current = true; + getSocket().emit('dev:quick_match', { skipTo: 'penalty_ban' }); + }, [selfUserId]); + + const handleLeave = () => { + if (matchId) { + try { + getSocket().emit('match:leave', { matchId }); + } catch { + // best-effort; navigating away regardless + } + } + router.push('/'); + }; + + if (!selfUserId) { + return ( +
+ Log in to run the penalty simulation. +
+ ); + } + + if (!match) { + return ( +
+ Starting penalty simulation… +
+ ); + } + + const opponent = match.opponent; + + return ( + + ); +} + +export default function DevPenaltiesPage() { + if (process.env.NODE_ENV !== 'development') { + return ( +
+ Dev only +
+ ); + } + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a8b7ee48..33a56fb0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { headers } from "next/headers"; import "@fontsource-variable/nunito"; import "@fontsource/poppins/600.css"; +import "@fontsource/poppins/700.css"; import "flag-icons/css/flag-icons.min.css"; import { Providers } from "./providers"; import { Analytics } from "@vercel/analytics/next"; diff --git a/src/components/PostHogProvider.tsx b/src/components/PostHogProvider.tsx index 881060f1..032199a7 100644 --- a/src/components/PostHogProvider.tsx +++ b/src/components/PostHogProvider.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect } from 'react'; import type { ReactElement } from 'react'; import { usePathname, useSearchParams } from 'next/navigation'; import posthog from 'posthog-js'; +import { consumeExitToPlayPending, trackExitToPlayLanded } from '@/lib/analytics/game-events'; export function PostHogPageView(): ReactElement { return ( @@ -37,5 +38,17 @@ function PostHogPageViewInner(): ReactElement { } }, [pathname, searchParams]); + useEffect(() => { + if (!pathname) return; + const normalizedPath = pathname.replace(/\/$/, ''); + if (normalizedPath !== '/play' && !normalizedPath.endsWith('/play')) return; + const pendingExit = consumeExitToPlayPending(); + if (!pendingExit) return; + trackExitToPlayLanded({ + ...pendingExit, + landedPath: pathname, + }); + }, [pathname]); + return <>; } diff --git a/src/components/__tests__/PostHogProvider.test.tsx b/src/components/__tests__/PostHogProvider.test.tsx new file mode 100644 index 00000000..c96b3102 --- /dev/null +++ b/src/components/__tests__/PostHogProvider.test.tsx @@ -0,0 +1,62 @@ +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const navigationMocks = vi.hoisted(() => ({ + pathname: '/game', + searchParams: new URLSearchParams(), +})); + +const analyticsMocks = vi.hoisted(() => ({ + consumeExitToPlayPending: vi.fn(), + trackExitToPlayLanded: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + usePathname: () => navigationMocks.pathname, + useSearchParams: () => navigationMocks.searchParams, +})); + +vi.mock('posthog-js', () => ({ + default: { + capture: vi.fn(), + }, +})); + +vi.mock('@/lib/analytics/game-events', () => analyticsMocks); + +import { PostHogPageView } from '../PostHogProvider'; + +describe('PostHogPageView', () => { + beforeEach(() => { + vi.clearAllMocks(); + navigationMocks.pathname = '/game'; + navigationMocks.searchParams = new URLSearchParams(); + }); + + it('emits the pending results-exit landing event once /play is reached', async () => { + const pendingExit = { + source: 'results_main_menu', + matchId: 'match-1', + matchType: 'ranked', + mode: 'ranked', + variant: 'ranked_sim', + resultVersion: 9, + hadFinalResults: true, + finalResultsAckSent: true, + stage: 'finalResults', + startedAtMs: 100, + fromPath: '/game', + }; + navigationMocks.pathname = '/play'; + analyticsMocks.consumeExitToPlayPending.mockReturnValue(pendingExit); + + render(); + + await waitFor(() => { + expect(analyticsMocks.trackExitToPlayLanded).toHaveBeenCalledWith({ + ...pendingExit, + landedPath: '/play', + }); + }); + }); +}); diff --git a/src/components/auth/OAuthCallbackScreen.tsx b/src/components/auth/OAuthCallbackScreen.tsx index d2856630..0b919cdb 100644 --- a/src/components/auth/OAuthCallbackScreen.tsx +++ b/src/components/auth/OAuthCallbackScreen.tsx @@ -1,21 +1,40 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Button } from '../ui/button'; -import { Card, CardContent } from '../ui/card'; -import { AlertCircle, ArrowLeft } from 'lucide-react'; +import { AlertCircle, ArrowLeft, RotateCcw } from 'lucide-react'; import { motion } from 'motion/react'; -import { parseOAuthHash, refreshWithToken } from '@/lib/auth/auth.service'; +import { + consumeRedirectOAuthProvider, + parseOAuthHash, + refreshWithTokenDetailed, + restorePendingDeletionWithToken, +} from '@/lib/auth/auth.service'; import { useAuthStore } from '@/stores/auth.store'; import { logger } from '@/utils/logger'; import { LoadingScreen } from '@/components/shared/LoadingScreen'; import { getPostAuthEntryRoute } from '@/lib/auth/postAuthRedirect'; +import { isOnboardingComplete } from '@/lib/auth/onboarding'; +import { trackLoginCompleted, trackSignupCompleted } from '@/lib/analytics/game-events'; import { useLocale } from '@/contexts/LocaleContext'; +// Provider error codes that mean "the user backed out", not "auth is broken". +// These are silently redirected home; every other error surfaces the failure UI. +const OAUTH_CANCELLATION_CODES = new Set([ + 'access_denied', + 'user_cancelled', + 'consent_required', + 'interaction_required', + 'login_required', +]); + export function OAuthCallbackScreen() { const { t } = useLocale(); const router = useRouter(); const bootstrap = useAuthStore((state) => state.bootstrap); const [error, setError] = useState(null); + const [pendingRestoreAvailable, setPendingRestoreAvailable] = useState(false); + const [restoreSubmitting, setRestoreSubmitting] = useState(false); + const [restoreError, setRestoreError] = useState(null); useEffect(() => { const processCallback = async () => { @@ -35,6 +54,31 @@ export function OAuthCallbackScreen() { return; } } + // User cancelled / denied the provider consent (e.g. tapped "Cancel" on + // the Facebook or Google screen). The provider redirects back with an + // `error` param and no tokens — that's NOT a failure, so don't log an + // error or show the failure screen; quietly return to the landing page. + // Supabase OAuth returns the error in the HASH fragment + // (#error=access_denied&error_description=...), not the query string, so + // check both. + const hashParams = new URLSearchParams(hash.replace(/^#/, "")); + const oauthError = searchParams.get("error") ?? hashParams.get("error"); + if (oauthError) { + const description = + searchParams.get("error_description") ?? hashParams.get("error_description") ?? undefined; + // Only the user-cancellation codes are benign — quietly return to the + // landing page. Any other provider error (server_error, misconfig, etc.) + // is a real failure and must surface via the catch below, not be hidden. + if (OAUTH_CANCELLATION_CODES.has(oauthError)) { + logger.info("OAuth callback: user cancelled or denied consent", { error: oauthError, description }); + window.history.replaceState({}, document.title, window.location.pathname); + router.replace("/"); + return; + } + logger.error("OAuth callback: provider returned an error", { error: oauthError, description }); + throw new Error(description ?? oauthError); + } + const lastHash = window.sessionStorage.getItem("quizball_oauth_hash"); const lastQuery = window.sessionStorage.getItem("quizball_oauth_query"); @@ -58,16 +102,21 @@ export function OAuthCallbackScreen() { ? { accessToken: queryAccessToken, refreshToken: queryRefreshToken } : null); if (tokens) { + const refreshed = await refreshWithTokenDetailed(tokens.refreshToken); + if (!refreshed.ok && refreshed.pendingDeletion) { + window.history.replaceState({}, document.title, window.location.pathname); + setPendingRestoreAvailable(true); + return; + } + if (!refreshed.ok) { + throw new Error(t('oauthCallback.sessionEstablishError')); + } if (hash) { window.sessionStorage.setItem("quizball_oauth_hash", hash); } if (query) { window.sessionStorage.setItem("quizball_oauth_query", query); } - const refreshed = await refreshWithToken(tokens.refreshToken); - if (!refreshed) { - throw new Error(t('oauthCallback.sessionEstablishError')); - } logger.info('OAuth callback session established'); // Clear tokens from URL (security: don't leave in browser history) window.history.replaceState({}, document.title, window.location.pathname); @@ -84,6 +133,16 @@ export function OAuthCallbackScreen() { if (!authenticatedUser) { throw new Error(t('oauthCallback.sessionLoadError')); } + const provider = consumeRedirectOAuthProvider(); + if (provider) { + // OAuth callbacks don't expose a precise new-vs-returning signal; onboarding + // state is the best available heuristic and matches the post-auth route. + if (isOnboardingComplete(authenticatedUser)) { + trackLoginCompleted(provider); + } else { + trackSignupCompleted(provider); + } + } logger.info('OAuth callback bootstrap success'); router.replace(getPostAuthEntryRoute(authenticatedUser)); } catch (err) { @@ -94,6 +153,74 @@ export function OAuthCallbackScreen() { processCallback(); }, [bootstrap, router, t]); + const handleRestore = async () => { + if (!pendingRestoreAvailable || restoreSubmitting) { + return; + } + setRestoreSubmitting(true); + setRestoreError(null); + try { + await restorePendingDeletionWithToken(); + await bootstrap({ force: true }); + const authenticatedUser = useAuthStore.getState().user; + if (!authenticatedUser) { + throw new Error(t('oauthCallback.sessionLoadError')); + } + router.replace(getPostAuthEntryRoute(authenticatedUser)); + } catch (err) { + logger.error('OAuth pending deletion restore failed', err); + setRestoreError(err instanceof Error ? err.message : t('oauthCallback.restoreFailed')); + setRestoreSubmitting(false); + } + }; + + if (pendingRestoreAvailable) { + return ( +
+ +
+
+ +
+
+

+ {t('oauthCallback.restoreAccountTitle')} +

+

+ {t('oauthCallback.restoreAccountDescription')} +

+
+ {restoreError ? ( +

+ {restoreError} +

+ ) : null} + + +
+
+
+ ); + } + if (error) { return (
diff --git a/src/components/auth/__tests__/OAuthCallbackScreen.test.tsx b/src/components/auth/__tests__/OAuthCallbackScreen.test.tsx new file mode 100644 index 00000000..89886e87 --- /dev/null +++ b/src/components/auth/__tests__/OAuthCallbackScreen.test.tsx @@ -0,0 +1,133 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type React from "react"; +import type { User } from "@/lib/types"; + +const replaceMock = vi.fn(); +const bootstrapMock = vi.fn(); +const refreshWithTokenDetailedMock = vi.fn(); +const consumeRedirectOAuthProviderMock = vi.fn(); +const trackSignupCompletedMock = vi.fn(); +const trackLoginCompletedMock = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: replaceMock }), +})); + +vi.mock("motion/react", () => ({ + motion: { + div: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + }, +})); + +vi.mock("@/contexts/LocaleContext", () => ({ + useLocale: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/components/shared/LoadingScreen", () => ({ + LoadingScreen: ({ text }: { text: string }) =>
{text}
, +})); + +vi.mock("@/lib/auth/auth.service", () => ({ + consumeRedirectOAuthProvider: () => consumeRedirectOAuthProviderMock(), + parseOAuthHash: (hash: string) => { + const params = new URLSearchParams(hash.replace(/^#/, "")); + const accessToken = params.get("access_token"); + const refreshToken = params.get("refresh_token"); + return accessToken && refreshToken ? { accessToken, refreshToken } : null; + }, + refreshWithTokenDetailed: (...args: unknown[]) => refreshWithTokenDetailedMock(...args), + restorePendingDeletionWithToken: vi.fn(), + logout: vi.fn(), + refreshSession: vi.fn(), +})); + +vi.mock("@/lib/analytics/game-events", () => ({ + trackSignupCompleted: (method: string) => trackSignupCompletedMock(method), + trackLoginCompleted: (method: string) => trackLoginCompletedMock(method), + trackLogout: vi.fn(), +})); + +import { OAuthCallbackScreen } from "@/components/auth/OAuthCallbackScreen"; +import { useAuthStore } from "@/stores/auth.store"; + +function makeUser(onboardingComplete: boolean): User { + return { + id: "u1", + email: "user@example.com", + phone_number: null, + phone_verified_at: null, + role: "user", + nickname: null, + country: null, + avatar_url: null, + avatar_customization: null, + favorite_club: null, + preferred_language: null, + onboarding_complete: onboardingComplete, + progression: { + level: 1, + currentLevelXp: 0, + xpForNextLevel: 100, + totalXp: 0, + progressPct: 0, + }, + created_at: "2026-06-02T00:00:00.000Z", + }; +} + +describe("OAuthCallbackScreen analytics", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.history.replaceState({}, "", "/auth/callback#access_token=access&refresh_token=refresh"); + useAuthStore.setState({ + status: "loading", + user: null, + hasBootstrapped: false, + bootstrap: bootstrapMock, + }); + refreshWithTokenDetailedMock.mockResolvedValue({ ok: true }); + consumeRedirectOAuthProviderMock.mockReturnValue("google"); + }); + + it("tracks signup_completed for a successful callback when the user still needs onboarding", async () => { + bootstrapMock.mockImplementation(async () => { + useAuthStore.setState({ user: makeUser(false), status: "authenticated" }); + }); + + render(); + + await waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/onboarding")); + expect(trackSignupCompletedMock).toHaveBeenCalledWith("google"); + expect(trackLoginCompletedMock).not.toHaveBeenCalled(); + }); + + it("tracks login_completed for a successful callback when the user is onboarded", async () => { + consumeRedirectOAuthProviderMock.mockReturnValue("facebook"); + bootstrapMock.mockImplementation(async () => { + useAuthStore.setState({ user: makeUser(true), status: "authenticated" }); + }); + + render(); + + await waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/play")); + expect(trackLoginCompletedMock).toHaveBeenCalledWith("facebook"); + expect(trackSignupCompletedMock).not.toHaveBeenCalled(); + }); + + it("does not track completion when the callback fails", async () => { + refreshWithTokenDetailedMock.mockResolvedValue({ ok: false, pendingDeletion: false }); + + render(); + + await waitFor(() => { + expect(screen.getByText("oauthCallback.authenticationFailed")).toBeInTheDocument(); + }); + expect(trackSignupCompletedMock).not.toHaveBeenCalled(); + expect(trackLoginCompletedMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/auth/__tests__/WelcomeScreen.test.tsx b/src/components/auth/__tests__/WelcomeScreen.test.tsx index bd39d97e..d90c95df 100644 --- a/src/components/auth/__tests__/WelcomeScreen.test.tsx +++ b/src/components/auth/__tests__/WelcomeScreen.test.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiError } from '@/lib/api/api'; // JSDOM doesn't ship with IntersectionObserver, which motion/react uses for // `whileInView`. Stub it before any component imports. @@ -57,21 +58,50 @@ vi.mock('@/stores/auth.store', () => ({ // Auth service — these are the six calls the screen issues. const loginMock = vi.fn(async (_email: string, _password: string) => ({ id: 'u1' })); -const registerMock = vi.fn(async (_payload: { email: string; password: string }) => ({ user: { id: 'u1' }, tokensSet: true })); +type RegisterMockResult = { + user: { id: string } | null; + tokensSet: boolean; + alreadyRegistered: boolean; + pendingDeletion: boolean; +}; +const registerMock = vi.fn( + async (_payload: { email: string; password: string }): Promise => ({ + user: { id: 'u1' }, + tokensSet: true, + alreadyRegistered: false, + pendingDeletion: false, + }), +); +const restorePendingDeletionWithLoginMock = vi.fn(async (_email: string, _password: string) => ({ id: 'u1' })); const socialLoginMock = vi.fn(async (_provider: string, _redirect: string) => undefined); -const socialLoginWithIdTokenMock = vi.fn(async (_provider: string, _idToken: string, _nonce: string) => undefined); +const socialLoginWithIdTokenMock = vi.fn( + async (_provider: string, _idToken: string, _nonce?: string, _options?: { restorePendingDeletion?: boolean }) => undefined, +); const startGeorgianPhoneOtpMock = vi.fn(async (_phone: string) => undefined); -const verifyGeorgianPhoneOtpMock = vi.fn(async (_phone: string, _code: string) => ({ id: 'u1' })); +const verifyGeorgianPhoneOtpMock = vi.fn( + async (_phone: string, _code: string, _options?: { restorePendingDeletion?: boolean }) => ({ id: 'u1' }), +); const forgotPasswordMock = vi.fn(async (_email: string, _redirectTo: string) => undefined); vi.mock('@/lib/auth/auth.service', () => ({ login: (email: string, password: string) => loginMock(email, password), register: (payload: { email: string; password: string }) => registerMock(payload), + restorePendingDeletionWithLogin: (email: string, password: string) => + restorePendingDeletionWithLoginMock(email, password), + isPendingDeletionAuthError: (error: unknown) => + error instanceof ApiError && + Boolean( + error.data && + typeof error.data === 'object' && + 'details' in error.data && + (error.data as { details?: { reason?: string } }).details?.reason === 'pending_deletion', + ), forgotPassword: (email: string, redirectTo: string) => forgotPasswordMock(email, redirectTo), socialLogin: (provider: string, redirect: string) => socialLoginMock(provider, redirect), - socialLoginWithIdToken: (provider: string, idToken: string, nonce: string) => - socialLoginWithIdTokenMock(provider, idToken, nonce), + socialLoginWithIdToken: (provider: string, idToken: string, nonce: string, options?: { restorePendingDeletion?: boolean }) => + socialLoginWithIdTokenMock(provider, idToken, nonce, options), startGeorgianPhoneOtp: (phone: string) => startGeorgianPhoneOtpMock(phone), - verifyGeorgianPhoneOtp: (phone: string, code: string) => verifyGeorgianPhoneOtpMock(phone, code), + verifyGeorgianPhoneOtp: (phone: string, code: string, options?: { restorePendingDeletion?: boolean }) => + verifyGeorgianPhoneOtpMock(phone, code, options), })); const georgianPhoneAvailabilityMock = vi.fn(() => ({ @@ -83,22 +113,31 @@ vi.mock('@/lib/auth/useGeorgianPhoneAuthAvailability', () => ({ useGeorgianPhoneAuthAvailability: () => georgianPhoneAvailabilityMock(), })); -// Google Identity — clientId comes from process.env; the screen calls -// `signInWithGoogleIdentity` if present. Stub it to a sentinel so we can -// assert it ran with the right id. +// Google Identity. The overlaid GIS button (renderGoogleButton) is the primary +// path; clicking the visible yellow button triggers the fallback +// (signInWithGoogleIdentity One Tap → redirect). We capture renderGoogleButton's +// onCredential callback so a test can drive the primary path directly. const signInWithGoogleIdentityMock = vi.fn(async (_clientId: string) => ({ idToken: 'tok', nonce: 'nonce' })); +let lastGoogleButtonCredentialCb: ((c: { idToken: string; nonce: string }) => void) | null = null; +const renderGoogleButtonMock = vi.fn( + async ( + _clientId: string, + _container: HTMLElement, + _width: number, + onCredential: (c: { idToken: string; nonce: string }) => void, + ) => { + lastGoogleButtonCredentialCb = onCredential; + return true; + }, +); vi.mock('@/lib/auth/google-identity', () => ({ signInWithGoogleIdentity: (clientId: string) => signInWithGoogleIdentityMock(clientId), -})); - -// In-app-browser helpers. -const isInAppBrowserMock = vi.fn(() => false); -const tryOpenInExternalBrowserMock = vi.fn((_url: string) => true); -const getPlatformMock = vi.fn(() => 'ios' as const); -vi.mock('@/lib/auth/in-app-browser', () => ({ - isInAppBrowser: () => isInAppBrowserMock(), - tryOpenInExternalBrowser: (url: string) => tryOpenInExternalBrowserMock(url), - getPlatform: () => getPlatformMock(), + renderGoogleButton: ( + clientId: string, + container: HTMLElement, + width: number, + onCredential: (c: { idToken: string; nonce: string }) => void, + ) => renderGoogleButtonMock(clientId, container, width, onCredential), })); // Analytics — we assert event names + payloads. @@ -211,7 +250,9 @@ beforeEach(() => { loginMock.mockReset(); loginMock.mockResolvedValue({ id: 'u1' }); registerMock.mockReset(); - registerMock.mockResolvedValue({ user: { id: 'u1' }, tokensSet: true }); + registerMock.mockResolvedValue({ user: { id: 'u1' }, tokensSet: true, alreadyRegistered: false, pendingDeletion: false }); + restorePendingDeletionWithLoginMock.mockReset(); + restorePendingDeletionWithLoginMock.mockResolvedValue({ id: 'u1' }); socialLoginMock.mockReset(); socialLoginMock.mockResolvedValue(undefined); socialLoginWithIdTokenMock.mockReset(); @@ -230,8 +271,8 @@ beforeEach(() => { forgotPasswordMock.mockResolvedValue(undefined); signInWithGoogleIdentityMock.mockReset(); signInWithGoogleIdentityMock.mockResolvedValue({ idToken: 'tok', nonce: 'nonce' }); - isInAppBrowserMock.mockReturnValue(false); - tryOpenInExternalBrowserMock.mockReturnValue(true); + renderGoogleButtonMock.mockClear(); + lastGoogleButtonCredentialCb = null; trackLoginCompletedMock.mockClear(); trackSignupCompletedMock.mockClear(); trackSignupStartedMock.mockClear(); @@ -293,7 +334,7 @@ describe('WelcomeScreen — Google sign-in flow', () => { clickContinueWithGoogle(); expect(trackSignupStartedMock).toHaveBeenCalledWith('google'); await waitFor(() => expect(signInWithGoogleIdentityMock).toHaveBeenCalledWith('test-google-client')); - expect(socialLoginWithIdTokenMock).toHaveBeenCalledWith('google', 'tok', 'nonce'); + expect(socialLoginWithIdTokenMock).toHaveBeenCalledWith('google', 'tok', 'nonce', undefined); expect(bootstrapMock).toHaveBeenCalledWith({ force: true }); }); @@ -311,28 +352,37 @@ describe('WelcomeScreen — Google sign-in flow', () => { expect(bootstrapMock).not.toHaveBeenCalled(); }); - it('takes the in-app-browser branch instead of starting GIS', async () => { - isInAppBrowserMock.mockReturnValueOnce(true); + it('falls back to the visible Google handler when GIS reports rendered but mounts no clickable button', async () => { render(); openLoginDialog(); + await waitFor(() => expect(renderGoogleButtonMock).toHaveBeenCalled()); + await act(async () => { + await Promise.resolve(); + }); + clickContinueWithGoogle(); - expect(tryOpenInExternalBrowserMock).toHaveBeenCalled(); - expect(signInWithGoogleIdentityMock).not.toHaveBeenCalled(); - expect(socialLoginMock).not.toHaveBeenCalled(); + + await waitFor(() => expect(signInWithGoogleIdentityMock).toHaveBeenCalledWith('test-google-client')); + expect(socialLoginWithIdTokenMock).toHaveBeenCalledWith('google', 'tok', 'nonce', undefined); + expect(bootstrapMock).toHaveBeenCalledWith({ force: true }); }); - it('shows the in-app-browser instructions panel after the bounce timeout', () => { - vi.useFakeTimers(); - isInAppBrowserMock.mockReturnValue(true); + it('renders the overlaid GIS button and exchanges its credential for a session', async () => { render(); openLoginDialog(); - clickContinueWithGoogle(); - // Instructions hidden until the 1500ms timer fires. - expect(screen.queryByText(/inAppBrowser\.title/)).not.toBeInTheDocument(); + // The overlaid Google button is rendered with the configured client id. + await waitFor(() => expect(renderGoogleButtonMock).toHaveBeenCalled()); + expect(renderGoogleButtonMock.mock.calls[0]?.[0]).toBe('test-google-client'); + // Drive the primary path: Google returns a credential via the rendered button. + expect(lastGoogleButtonCredentialCb).toBeTruthy(); act(() => { - vi.advanceTimersByTime(1600); + lastGoogleButtonCredentialCb?.({ idToken: 'btn-tok', nonce: 'btn-nonce' }); }); - expect(screen.getByText(/inAppBrowser\.title/)).toBeInTheDocument(); + expect(trackSignupStartedMock).toHaveBeenCalledWith('google'); + await waitFor(() => + expect(socialLoginWithIdTokenMock).toHaveBeenCalledWith('google', 'btn-tok', 'btn-nonce', undefined), + ); + expect(bootstrapMock).toHaveBeenCalledWith({ force: true }); }); }); @@ -368,15 +418,27 @@ describe('WelcomeScreen — email signin / signup', () => { await waitFor(() => expect(bootstrapMock).toHaveBeenCalled()); }); - it('shows the check-email notice when register reports tokensSet=false', async () => { - registerMock.mockResolvedValueOnce({ user: null as unknown as { id: string }, tokensSet: false }); + it('shows the check-email modal when register reports tokensSet=false (new signup)', async () => { + registerMock.mockResolvedValueOnce({ user: null as unknown as { id: string }, tokensSet: false, alreadyRegistered: false, pendingDeletion: false }); render(); openLoginDialog(); openAuthOptions(); fireEvent.click(screen.getByText(/welcome\.signUpTab/)); setEmailFields('new@example.com', 'secret12', 'secret12'); fireEvent.click(screen.getByText(/welcome\.createAccount/)); - await waitFor(() => expect(screen.getByText(/welcome\.checkEmail/)).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText(/welcome\.checkEmailTitle/)).toBeInTheDocument()); + expect(bootstrapMock).not.toHaveBeenCalled(); + }); + + it('shows the already-registered modal when register reports alreadyRegistered=true', async () => { + registerMock.mockResolvedValueOnce({ user: null as unknown as { id: string }, tokensSet: false, alreadyRegistered: true, pendingDeletion: false }); + render(); + openLoginDialog(); + openAuthOptions(); + fireEvent.click(screen.getByText(/welcome\.signUpTab/)); + setEmailFields('existing@example.com', 'secret12', 'secret12'); + fireEvent.click(screen.getByText(/welcome\.createAccount/)); + await waitFor(() => expect(screen.getByText(/welcome\.alreadyRegisteredTitle/)).toBeInTheDocument()); expect(bootstrapMock).not.toHaveBeenCalled(); }); @@ -415,6 +477,55 @@ describe('WelcomeScreen — email signin / signup', () => { expect(bootstrapMock).not.toHaveBeenCalled(); }); + it('shows restore modal for pending-deletion email login and disables restore while submitting', async () => { + loginMock.mockRejectedValueOnce(new ApiError('Request failed', 401, { + code: 'AUTHENTICATION_ERROR', + details: { reason: 'pending_deletion' }, + })); + let resolveRestore: (() => void) | null = null; + restorePendingDeletionWithLoginMock.mockImplementationOnce( + () => new Promise<{ id: string }>((resolve) => { + resolveRestore = () => resolve({ id: 'u1' }); + }), + ); + + render(); + openLoginDialog(); + openAuthOptions(); + setEmailFields('user@example.com', 'secret'); + fireEvent.click(screen.getByText(/welcome\.signInWithEmail/)); + + await waitFor(() => expect(screen.getByText(/welcome\.restoreAccountTitle/)).toBeInTheDocument()); + const restoreButton = screen.getByRole('button', { name: 'welcome.restoreAccount' }) as HTMLButtonElement; + fireEvent.click(restoreButton); + fireEvent.click(restoreButton); + + await waitFor(() => expect(restorePendingDeletionWithLoginMock).toHaveBeenCalledTimes(1)); + expect(restorePendingDeletionWithLoginMock).toHaveBeenCalledWith('user@example.com', 'secret'); + await waitFor(() => expect(restoreButton.disabled).toBe(true)); + act(() => resolveRestore?.()); + await waitFor(() => expect(bootstrapMock).toHaveBeenCalledWith({ force: true })); + }); + + it('shows restore modal when signup reports a pending-deletion account', async () => { + registerMock.mockResolvedValueOnce({ + user: null as unknown as { id: string }, + tokensSet: false, + alreadyRegistered: true, + pendingDeletion: true, + }); + + render(); + openLoginDialog(); + openAuthOptions(); + fireEvent.click(screen.getByText(/welcome\.signUpTab/)); + setEmailFields('existing@example.com', 'secret12', 'secret12'); + fireEvent.click(screen.getByText(/welcome\.createAccount/)); + + await waitFor(() => expect(screen.getByText(/welcome\.restoreAccountTitle/)).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'welcome.restoreAccount' })).toBeInTheDocument(); + }); + it('surfaces the upstream error message on a failed signup', async () => { registerMock.mockRejectedValueOnce(new Error('Email already registered')); render(); @@ -467,7 +578,7 @@ describe('WelcomeScreen — email signin / signup', () => { }); describe('WelcomeScreen — phone OTP', () => { - it('hides the phone tab when Georgian phone auth is not available', () => { + it('hides the phone form when Georgian phone auth is not available', () => { georgianPhoneAvailabilityMock.mockReturnValue({ country: 'US', isAvailable: false, @@ -478,29 +589,34 @@ describe('WelcomeScreen — phone OTP', () => { openLoginDialog(); openAuthOptions(); - expect(screen.queryByText(/welcome\.phoneTab/)).not.toBeInTheDocument(); - expect(screen.queryByPlaceholderText(/welcome\.phonePlaceholder/)).not.toBeInTheDocument(); + // Phone is no longer a tab; when unavailable the phone form is absent under Sign In. + expect(screen.queryByPlaceholderText(/welcome\.phoneLocalPlaceholder/)).not.toBeInTheDocument(); }); - it('starts the OTP flow on first submit then verifies on second', async () => { + it('shows the phone form under Sign In via the Email|Phone toggle and runs the OTP flow', async () => { render(); openLoginDialog(); openAuthOptions(); - fireEvent.click(screen.getByText(/welcome\.phoneTab/)); - const phoneInput = screen.getByPlaceholderText(/welcome\.phonePlaceholder/); - fireEvent.change(phoneInput, { target: { value: '+995555000111' } }); + // Sign In defaults to the Email method; switch the toggle to Phone. + fireEvent.click(screen.getByText(/welcome\.phoneMethod/)); + // The +995 prefix is fixed; the user types only the 9 local digits. + const phoneInput = screen.getByPlaceholderText(/welcome\.phoneLocalPlaceholder/); + fireEvent.change(phoneInput, { target: { value: '555000111' } }); const sendButton = screen.getByText(/welcome\.sendCode/); fireEvent.click(sendButton); expect(trackSignupStartedMock).not.toHaveBeenCalledWith('phone'); await waitFor(() => expect(startGeorgianPhoneOtpMock).toHaveBeenCalledWith('+995555000111')); - await waitFor(() => expect(screen.getByText(/welcome\.phoneCodeSent/)).toBeInTheDocument()); + // After the code is sent the modal collapses to a focused step showing the + // persistent inline "code sent" hint + a change-number affordance. + await waitFor(() => expect(screen.getByText(/welcome\.otpSentHint/)).toBeInTheDocument()); + expect(screen.getByText(/welcome\.changeNumber/)).toBeInTheDocument(); // OTP input is now visible. Type 6 digits + verify. const otpInput = screen.getByPlaceholderText(/welcome\.otpPlaceholder/); fireEvent.change(otpInput, { target: { value: '123456' } }); const verifyButton = screen.getByText(/welcome\.verifyCode/); fireEvent.click(verifyButton); - await waitFor(() => expect(verifyGeorgianPhoneOtpMock).toHaveBeenCalledWith('+995555000111', '123456')); + await waitFor(() => expect(verifyGeorgianPhoneOtpMock).toHaveBeenCalledWith('+995555000111', '123456', undefined)); expect(trackLoginCompletedMock).toHaveBeenCalledWith('phone'); await waitFor(() => expect(bootstrapMock).toHaveBeenCalledWith({ force: true })); }); @@ -510,25 +626,38 @@ describe('WelcomeScreen — phone OTP', () => { render(); openLoginDialog(); openAuthOptions(); - fireEvent.click(screen.getByText(/welcome\.phoneTab/)); - const phoneInput = screen.getByPlaceholderText(/welcome\.phonePlaceholder/); - fireEvent.change(phoneInput, { target: { value: '+995555000111' } }); + fireEvent.click(screen.getByText(/welcome\.phoneMethod/)); + const phoneInput = screen.getByPlaceholderText(/welcome\.phoneLocalPlaceholder/); + fireEvent.change(phoneInput, { target: { value: '555000111' } }); fireEvent.click(screen.getByText(/welcome\.sendCode/)); await waitFor(() => expect(screen.getByText('SMS blocked here')).toBeInTheDocument()); }); }); -describe('WelcomeScreen — mode-switch reset behavior', () => { - it('switching tabs resets feedback and disables stale OTP state', () => { +describe('WelcomeScreen — sign-in method toggle', () => { + it('toggles between Email and Phone under Sign In, one form at a time', () => { render(); openLoginDialog(); openAuthOptions(); - fireEvent.click(screen.getByText(/welcome\.phoneTab/)); - const phoneInput = screen.getByPlaceholderText(/welcome\.phonePlaceholder/); - fireEvent.change(phoneInput, { target: { value: '+1' } }); - // Switch back to signin — the email form should appear. - fireEvent.click(screen.getByText(/welcome\.signInTab/)); + // Defaults to Email: email form shown, phone hidden. + expect(screen.getByPlaceholderText(/welcome\.emailPlaceholder/)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText(/welcome\.phoneLocalPlaceholder/)).not.toBeInTheDocument(); + // Toggle to Phone: phone form shown, email hidden. + fireEvent.click(screen.getByText(/welcome\.phoneMethod/)); + expect(screen.getByPlaceholderText(/welcome\.phoneLocalPlaceholder/)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText(/welcome\.emailPlaceholder/)).not.toBeInTheDocument(); + // Toggle back to Email. + fireEvent.click(screen.getByText(/welcome\.emailMethod/)); + expect(screen.getByPlaceholderText(/welcome\.emailPlaceholder/)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText(/welcome\.phoneLocalPlaceholder/)).not.toBeInTheDocument(); + }); + + it('hides the method toggle on Sign Up (email only)', () => { + render(); + openLoginDialog(); + openAuthOptions(); + fireEvent.click(screen.getByText(/welcome\.signUpTab/)); expect(screen.getByPlaceholderText(/welcome\.emailPlaceholder/)).toBeInTheDocument(); - expect(screen.queryByPlaceholderText(/welcome\.otpPlaceholder/)).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText(/welcome\.phoneLocalPlaceholder/)).not.toBeInTheDocument(); }); }); diff --git a/src/components/game/PossessionQuestionPanel.tsx b/src/components/game/PossessionQuestionPanel.tsx index 6a2c9f9a..179fc535 100644 --- a/src/components/game/PossessionQuestionPanel.tsx +++ b/src/components/game/PossessionQuestionPanel.tsx @@ -6,6 +6,7 @@ import type { GameQuestion } from '@/lib/domain/gameQuestion'; import type { AnswerStateArray, Phase } from '@/lib/types/game.types'; import { ArenaScoreSplash } from '@/components/game/ArenaScoreSplash'; import { MatchHudIconButton } from '@/features/possession/components/MatchHudPrimitives'; +import { MAX_PENALTY_ROUNDS } from '@/features/possession/types/possession.types'; import { useLocale } from '@/contexts/LocaleContext'; const poppins = { @@ -32,6 +33,11 @@ interface PossessionQuestionPanelProps { isPenaltyPhase: boolean; isShotPhase: boolean; isLastAttackPhase: boolean; + /** Penalty round to display (1..5 in regulation, 1 in sudden death). */ + penaltyDisplayRound?: number; + /** Penalty round total to display (5 in regulation, 1 in sudden death). */ + penaltyDisplayTotal?: number; + isPenaltySuddenDeath?: boolean; question: GameQuestion | null; qIndex: number; @@ -125,6 +131,9 @@ export function PossessionQuestionPanel({ phase, isPenaltyPhase, isShotPhase, + penaltyDisplayRound, + penaltyDisplayTotal, + isPenaltySuddenDeath = false, question, qIndex, totalQuestions, @@ -165,10 +174,18 @@ export function PossessionQuestionPanel({ const timerPillClass = 'flex shrink-0 items-center justify-center rounded-[16px] bg-brand-blue text-white tabular-nums'; - const questionCounterLabel = t('possession.questionCounter', { - current: displayQuestionNum, - total: totalQuestions, - }); + const questionCounterLabel = isPenaltyPhase + ? isPenaltySuddenDeath + // Sudden death restarts as a one-shot set; no "sudden death" wording. + ? t('possession.questionCounter', { current: 1, total: 1 }) + : t('possession.penaltyRound', { + round: penaltyDisplayRound ?? 1, + max: penaltyDisplayTotal ?? MAX_PENALTY_ROUNDS, + }) + : t('possession.questionCounter', { + current: displayQuestionNum, + total: totalQuestions, + }); const questionPill = (
{t(item.labelKey as MessageKey)} {showSocialBadge && ( - + {socialBadgeCount} )} @@ -92,7 +96,7 @@ export function Sidebar({ currentPath, socialBadgeCount = 0, className }: Sideba
{/* Beta badge — flags that this is a pre-release build. Matches the ranked question-kind badge style (tilted yellow pill). */} - + {t('common.beta')} ({ + connectSocket: () => socketStub, getSocket: () => socketStub, })); @@ -241,6 +242,19 @@ beforeEach(() => { routerPushMock.mockClear(); routerReplaceMock.mockClear(); socketEmitMock.mockClear(); + socketEmitMock.mockImplementation((event: string, payload?: unknown, ack?: (result: unknown) => void) => { + if (event === 'lobby:leave' && typeof ack === 'function') { + ack({ + ok: true, + lobbyId: 'L1', + closed: false, + correlationId: + payload && typeof payload === 'object' && 'correlationId' in payload + ? String((payload as { correlationId: unknown }).correlationId) + : 'test-correlation', + }); + } + }); clearRankedMatchmakingMock.mockClear(); startSessionMock.mockClear(); setGameStageMock.mockClear(); @@ -468,6 +482,7 @@ describe('AppShell — rejoin / completed / forfeit / draft banners', () => { }); renderShell(); expect(screen.getAllByText(/appShell.matchStillActiveAgainst/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/"name":"Opp"/).length).toBeGreaterThan(0); }); it('falls back to active-match banner when match exists with no finalResults', () => { @@ -497,6 +512,7 @@ describe('AppShell — rejoin / completed / forfeit / draft banners', () => { }); renderShell(); expect(screen.getAllByText(/appShell.matchFinishedAgainst/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/"name":"WinnerOpp"/).length).toBeGreaterThan(0); }); it('shows the forfeit-pending banner before finalResults are received', () => { @@ -526,6 +542,26 @@ describe('AppShell — rejoin / completed / forfeit / draft banners', () => { expect(screen.queryByText(/appShell.forfeit/)).not.toBeInTheDocument(); }); + it('hides the rejoin banner while a lobby room is handing off to an active match', () => { + pathnameMock.mockReturnValue('/friend/room/ROOM1'); + seedRealtime({ + match: { + matchId: 'M-HANDOFF', + mode: 'friendly', + opponent: { id: 'opp', username: 'Opp', avatarUrl: null }, + finalResults: null, + }, + sessionState: { + state: 'IN_WAITING_LOBBY', + waitingLobbyId: 'L1', + }, + }); + renderShell(); + expect(screen.queryByText(/appShell.matchStillActiveAgainst/)).not.toBeInTheDocument(); + expect(screen.queryByText(/appShell.rejoinMatch/)).not.toBeInTheDocument(); + expect(screen.queryByText(/appShell.forfeit/)).not.toBeInTheDocument(); + }); + it('shows party-dropout banner and hides rejoin for the dropped match', () => { pathnameMock.mockReturnValue('/leaderboard'); seedRealtime({ @@ -561,6 +597,27 @@ describe('AppShell — rejoin / completed / forfeit / draft banners', () => { }); renderShell(); expect(screen.getAllByText(/appShell.draftActiveAgainst/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/"name":"DraftOpp"/).length).toBeGreaterThan(0); + }); + + it('hides the draft banner while still inside the lobby room handoff route', () => { + pathnameMock.mockReturnValue('/friend/room/ROOM1'); + seedRealtime({ + lobby: { + status: 'active', + mode: 'friendly', + inviteCode: 'ROOM1', + lobbyId: 'L9', + displayName: 'L', + members: [ + { userId: 'self-user', username: 'me', avatarUrl: null }, + { userId: 'opp', username: 'DraftOpp', avatarUrl: null }, + ], + }, + draft: { state: 'in_progress' }, + }); + renderShell(); + expect(screen.queryByText(/appShell.draftActiveAgainst/)).not.toBeInTheDocument(); }); }); @@ -629,7 +686,7 @@ describe('AppShell — banner callbacks fire the right store / socket actions', expect(routerPushMock).toHaveBeenCalledWith('/friend/room/ROOM1'); }); - it('lobby banner Leave → emits lobby:leave, resets realtime, clears ranked matchmaking', () => { + it('lobby banner Leave → emits lobby:leave, resets realtime, clears ranked matchmaking', async () => { pathnameMock.mockReturnValue('/leaderboard'); const reset = vi.fn(); seedRealtime({ @@ -646,9 +703,13 @@ describe('AppShell — banner callbacks fire the right store / socket actions', renderShell(); const leaves = screen.getAllByText(/appShell.leave/); fireEvent.click(leaves[0]); - expect(socketEmitMock).toHaveBeenCalledWith('lobby:leave'); - expect(reset).toHaveBeenCalled(); - expect(clearRankedMatchmakingMock).toHaveBeenCalled(); + expect(socketEmitMock).toHaveBeenCalledWith('lobby:leave', { + correlationId: expect.any(String), + }, expect.any(Function)); + await waitFor(() => { + expect(reset).toHaveBeenCalled(); + expect(clearRankedMatchmakingMock).toHaveBeenCalled(); + }); }); it('completed-match banner View Results → routes to /game with finalResults stage', () => { diff --git a/src/components/layout/app-shell/AppShellBanners.tsx b/src/components/layout/app-shell/AppShellBanners.tsx index 59bc2a4f..54584e04 100644 --- a/src/components/layout/app-shell/AppShellBanners.tsx +++ b/src/components/layout/app-shell/AppShellBanners.tsx @@ -95,6 +95,9 @@ export function AppShellBanners({ variant, vm }: AppShellBannersProps) { const rankedReturnLabel = isDesktop ? 'appShell.returnToMatchmaking' : 'appShell.return'; const lobbyReturnLabel = isDesktop ? 'appShell.returnToLobby' : 'appShell.return'; const completedDismissLabel = isDesktop ? 'Dismiss' : t('appShell.dismiss'); + const completedOpponentName = completedMatchBanner?.opponent.username ?? t('appShell.opponentFallback'); + const draftOpponentName = activeDraftBanner?.opponent?.username ?? t('appShell.opponentFallback'); + const activeMatchOpponentName = activeMatchBanner?.opponent.username ?? t('appShell.opponentFallback'); return ( <> @@ -154,10 +157,7 @@ export function AppShellBanners({ variant, vm }: AppShellBannersProps) { {completedPartyQuiz ? ( t('appShell.partyQuizFinished') ) : ( - <> - {t(matchFinishedLabel)}{' '} - {completedMatchBanner?.opponent.username ?? t('appShell.opponentFallback')} - + t(matchFinishedLabel, { name: completedOpponentName }) )}

{t(completedDescKey)}

@@ -194,8 +194,7 @@ export function AppShellBanners({ variant, vm }: AppShellBannersProps) {

- {t(draftActiveKey)}{' '} - {activeDraftBanner?.opponent?.username ?? t('appShell.opponentFallback')} + {t(draftActiveKey, { name: draftOpponentName })}

{t(draftDescKey)}

@@ -222,8 +221,7 @@ export function AppShellBanners({ variant, vm }: AppShellBannersProps) {

- {t(rejoinActiveKey)}{' '} - {activeMatchBanner?.opponent.username ?? t('appShell.opponentFallback')} + {t(rejoinActiveKey, { name: activeMatchOpponentName })}

{activeMatchBanner?.source === 'rejoin' diff --git a/src/components/layout/app-shell/useAppShellViewModel.ts b/src/components/layout/app-shell/useAppShellViewModel.ts index b070337c..ea1546fc 100644 --- a/src/components/layout/app-shell/useAppShellViewModel.ts +++ b/src/components/layout/app-shell/useAppShellViewModel.ts @@ -25,6 +25,8 @@ import { useStoreWallet } from '@/lib/queries/store.queries'; import { useIncomingFriendRequestCount } from '@/lib/queries/social.queries'; import { getSocket } from '@/lib/realtime/socket-client'; import { useRealtimeConnection } from '@/lib/realtime/useRealtimeConnection'; +import { logger } from '@/utils/logger'; +import { useLobbyCommandMachine } from '@/features/friend/hooks/useLobbyCommandMachine'; import type { RankedGeoHintDebug } from './appShell.types'; import { @@ -73,6 +75,7 @@ export function useAppShellViewModel() { ); const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); const [nowTick, setNowTick] = useState(() => Date.now()); + const lobbyCommands = useLobbyCommandMachine(); useRealtimeConnection({ enabled: Boolean(authUser), selfUserId: authUser?.id ?? null }); // Tick once per second so any time-comparison render values @@ -85,8 +88,13 @@ export function useAppShellViewModel() { }, [suppressLobbyBannerUntil]); const currentPath = pathname ?? '/'; - const showHeader = HEADER_PATHS.some((p) => (p === '/' ? currentPath === '/' : currentPath.startsWith(p))); - const showNav = !HIDE_NAV_PATHS.some((path) => currentPath.startsWith(path)); + // A daily-challenge GAME (`/daily/challenges/`) is an immersive fullscreen + // experience like a ranked match — hide the app header AND bottom nav so they + // can't be reached and content can't scroll behind them. The hub + // (`/daily/challenges`) keeps both. + const inDailyChallengeGame = currentPath.startsWith('/daily/challenges/'); + const showHeader = !inDailyChallengeGame && HEADER_PATHS.some((p) => (p === '/' ? currentPath === '/' : currentPath.startsWith(p))); + const showNav = !inDailyChallengeGame && !HIDE_NAV_PATHS.some((path) => currentPath.startsWith(path)); const inLobbyRoom = currentPath.startsWith('/friend/room'); const lobbyBannerSuppressed = suppressLobbyBannerReason !== null || @@ -124,7 +132,7 @@ export function useAppShellViewModel() { opponent: draftOpponent, } : null; - const showDraftBanner = !!activeDraftBanner && !currentPath.startsWith('/game'); + const showDraftBanner = !!activeDraftBanner && !inLobbyRoom && !currentPath.startsWith('/game'); const activeMatchBanner = rejoinMatch ? { matchId: rejoinMatch.matchId, @@ -152,6 +160,7 @@ export function useAppShellViewModel() { !!activeMatchBanner && !forfeitPendingForActiveMatch && !partyDropoutForActiveMatch && + !inLobbyRoom && !currentPath.startsWith('/game'); const completedMatchBanner = matchBanner.finalResults ? { @@ -234,9 +243,16 @@ export function useAppShellViewModel() { }; const handleLeaveLobby = () => { - getSocket().emit('lobby:leave'); - resetRealtime(); - useRankedMatchmakingStore.getState().clearRankedMatchmaking(); + if (lobbyCommands.isLeaving) return; + void lobbyCommands.leaveLobby() + .then((result) => { + if (!result?.ok) return; + resetRealtime(); + useRankedMatchmakingStore.getState().clearRankedMatchmaking(); + }) + .catch((error) => { + logger.warn('Failed to leave lobby', { error }); + }); }; const handleRejoinMatch = () => { diff --git a/src/components/shared/BanCategoryCard.tsx b/src/components/shared/BanCategoryCard.tsx index 3af9355c..68a1f1fe 100644 --- a/src/components/shared/BanCategoryCard.tsx +++ b/src/components/shared/BanCategoryCard.tsx @@ -2,7 +2,7 @@ /* eslint-disable @next/next/no-img-element -- Category artwork URLs come from realtime/backend payloads. */ -import { memo, useMemo, useState } from 'react'; +import { memo, useMemo } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { cn } from '@/lib/utils'; import { useLocale } from '@/contexts/LocaleContext'; @@ -80,15 +80,8 @@ function BanCategoryCardComponent({ const color = BAN_CARD_COLORS[colorIndex % BAN_CARD_COLORS.length]; const imageUrl = category.imageUrl ?? null; const hasImage = Boolean(imageUrl); - const [imageLoaded, setImageLoaded] = useState(false); - // Reset the fade-in state when the image source changes, the React-recommended - // "adjust state during render" way — avoids a setState-in-effect cascade. - const [loadedImageUrl, setLoadedImageUrl] = useState(imageUrl); - if (loadedImageUrl !== imageUrl) { - setLoadedImageUrl(imageUrl); - setImageLoaded(false); - } const interactive = !disabled && !isBanned && !!onClick; + // Staggered entrance so cards cascade in rather than popping all at once. const entranceTransition = useMemo( () => ({ ...CARD_SPRING, delay: 0.2 + animationIndex * 0.08 }), [animationIndex], @@ -126,12 +119,13 @@ function BanCategoryCardComponent({ setImageLoaded(true)} + fetchPriority="high" className={cn( - 'absolute inset-0 h-full w-full object-cover transition-[opacity,transform,filter] duration-300 ease-out sm:duration-500', - imageLoaded ? 'opacity-100' : 'opacity-0', + 'absolute inset-0 h-full w-full object-cover transition-[transform,filter] duration-300 ease-out sm:duration-500', interactive && 'group-hover:scale-105', isBanned && 'grayscale' )} @@ -195,12 +189,13 @@ function BanCategoryCardComponent({ )} - {/* Banned stamp overlay — flat style to match the /play cards */} + {/* Banned stamp overlay — slams in when the card is banned. */} {isBanned && ( setRoomCode(e.target.value.toUpperCase())} - maxLength={8} + onChange={(e) => setRoomCode(e.target.value)} + maxLength={256} /> - ))} -

- )} +
+

+ {t('dailyGames.spellingTip')} +

{/* Recent Answer Feedback */} {recentAnswer && (
-
+
@@ -317,40 +297,44 @@ export function CountdownGame({ session, onBack, onComplete }: CountdownGameProp
)} - {/* Found Answers */} -
-
-

{t("dailyGames.answersFound")}

- + {/* Answers found — soft list with green chips, matching ranked. */} +
+
+

+ {t("dailyGames.answersFound")} +

+ {foundAnswers.length}
{foundAnswers.length === 0 ? ( -

- No answers found yet. Start typing! +

+ {t("dailyGames.noAnswersYet")}

) : ( -
- {foundAnswers.map((answer, index) => ( +
+ {foundAnswers.map((answer) => (
- - {answer} + {answer}
))}
)}
- {/* Skip Button */} + {/* Skip / Next Round — green submit-button style, matching the other + daily challenges' primary action. */}
diff --git a/src/features/daily/FootballLogicGame.tsx b/src/features/daily/FootballLogicGame.tsx index f89779ef..0a84a9e8 100644 --- a/src/features/daily/FootballLogicGame.tsx +++ b/src/features/daily/FootballLogicGame.tsx @@ -8,7 +8,8 @@ import { CheckCircle2, XCircle } from "lucide-react"; import { Input } from "@/components/ui/input"; import type { FootballLogicSession } from "@/lib/domain/dailyChallenge"; import { getDailyChallengeCopy } from "@/lib/i18n/dailyChallenge"; -import { findAcceptedAnswer } from "./daily-challenge.utils"; +import { fuzzyMatchesAnswer } from "@/lib/answerMatching"; +import { playSfx } from "@/lib/sounds/gameSounds"; import { QuitGameDialog } from "./QuitGameDialog"; import { DailyChallengeHeader } from "./components/DailyChallengeHeader"; @@ -59,9 +60,9 @@ export function FootballLogicGame({ return; } - const matchedAnswer = findAcceptedAnswer(answer, currentQuestion.acceptedAnswers); - const isCorrect = matchedAnswer !== null; + const isCorrect = fuzzyMatchesAnswer(answer, currentQuestion.acceptedAnswers); + playSfx(isCorrect ? "dailyCorrect" : "wrongAnswer"); if (isCorrect) { setCorrectCount((previous) => previous + 1); } @@ -173,8 +174,8 @@ export function FootballLogicGame({ } }} placeholder={copy.typeYourAnswer} - className="h-[48px] sm:h-[56px] rounded-[16px] bg-surface-page text-white text-center placeholder:text-white/35 flex-1" - style={{ ...poppins, fontSize: 'clamp(14px, 1.7vw, 22px)', fontWeight: 500, border: '2px solid rgba(255,255,255,0.1)' }} + className="h-[48px] sm:h-[56px] rounded-[16px] bg-surface-card-tint border-2 border-surface-card text-white text-center placeholder:text-brand-slate focus:border-brand-cyan focus-visible:border-brand-cyan focus-visible:ring-brand-cyan/50 flex-1" + style={{ ...poppins, fontSize: 'clamp(16px, 1.7vw, 22px)', fontWeight: 500 }} autoFocus /> diff --git a/src/features/friend/components/CreateJoinPanel.tsx b/src/features/friend/components/CreateJoinPanel.tsx index 0c6a0fc4..d557193a 100644 --- a/src/features/friend/components/CreateJoinPanel.tsx +++ b/src/features/friend/components/CreateJoinPanel.tsx @@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from "react"; import { Plus, Lock, Globe, ArrowRight, Loader2 } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { toast } from "sonner"; -import { getSocket } from "@/lib/realtime/socket-client"; import { trackFriendInviteAccepted } from "@/lib/analytics/game-events"; import { useRealtimeMatchStore } from "@/stores/realtimeMatch.store"; import { useLocale } from "@/contexts/LocaleContext"; +import { extractFriendInviteCode } from "@/lib/friend/inviteCode"; +import { useLobbyCommandMachine } from "../hooks/useLobbyCommandMachine"; interface CreateJoinPanelProps { onActionTriggered?: () => void; @@ -22,101 +23,78 @@ export function CreateJoinPanel({ onActionTriggered }: CreateJoinPanelProps) { const { t } = useLocale(); const [inviteCode, setInviteCode] = useState(""); const [isPublic, setIsPublic] = useState(false); - const [isCreating, setIsCreating] = useState(false); - const [isJoining, setIsJoining] = useState(false); + const lobbyCommands = useLobbyCommandMachine(); + const { createLobby, joinByCode, reset } = lobbyCommands; const lobby = useRealtimeMatchStore((state) => state.lobby); - const error = useRealtimeMatchStore((state) => state.error); - const createTimeoutRef = useRef | null>(null); - const joinTimeoutRef = useRef | null>(null); - const clearCreateTimeout = () => { - if (!createTimeoutRef.current) return; - clearTimeout(createTimeoutRef.current); - createTimeoutRef.current = null; - }; + // On a fast connection the socket ACK resolves in a few ms, so the real + // isCreating/isJoining flags flip on and off before the user can perceive it. + // Hold a local pressed state for a minimum window so the spinner is always + // visible (optimistic feedback). + const [createPressed, setCreatePressed] = useState(false); + const [joinPressed, setJoinPressed] = useState(false); + const createTimerRef = useRef | null>(null); + const joinTimerRef = useRef | null>(null); + const isCreating = lobbyCommands.isCreating || createPressed; + const isJoining = lobbyCommands.isJoining || joinPressed; - const clearJoinTimeout = () => { - if (!joinTimeoutRef.current) return; - clearTimeout(joinTimeoutRef.current); - joinTimeoutRef.current = null; - }; + useEffect(() => () => { + if (createTimerRef.current) clearTimeout(createTimerRef.current); + if (joinTimerRef.current) clearTimeout(joinTimerRef.current); + }, []); const handleCreate = () => { - if (isCreating) return; + if (lobbyCommands.isBusy || createPressed) return; if (onActionTriggered) onActionTriggered(); - setIsCreating(true); - clearCreateTimeout(); - createTimeoutRef.current = setTimeout(() => { - setIsCreating(false); - toast.error(t("friend.createTimedOut")); - }, 8000); - getSocket().emit("lobby:create", { mode: "friendly", isPublic }); + setCreatePressed(true); + createTimerRef.current = setTimeout(() => setCreatePressed(false), 600); toast.info(t("friend.creatingRoom")); + void createLobby({ mode: "friendly", isPublic }).then((result) => { + if (!result || result.ok) return; + toast.error(result.message); + }); }; const handleJoin = () => { - if (isJoining) return; - if (!inviteCode || inviteCode.length < 3) { + if (lobbyCommands.isBusy || joinPressed) return; + const code = extractFriendInviteCode(inviteCode); + if (!code) { toast.error(t("friend.enterValidCode")); return; } if (onActionTriggered) onActionTriggered(); - setIsJoining(true); - clearJoinTimeout(); - joinTimeoutRef.current = setTimeout(() => { - setIsJoining(false); - toast.error(t("friend.joinTimedOut")); - }, 8000); - const code = inviteCode.trim().toUpperCase(); + setJoinPressed(true); + joinTimerRef.current = setTimeout(() => setJoinPressed(false), 600); try { trackFriendInviteAccepted(code); } catch (error) { console.error('Analytics trackFriendInviteAccepted failed', error); } - getSocket().emit("lobby:join_by_code", { inviteCode: code }); toast.info(t("friend.joiningCode", { code })); + void joinByCode(code).then((result) => { + if (!result || result.ok) return; + toast.error(result.message); + }); }; useEffect(() => { if (!lobby) return; - clearCreateTimeout(); - clearJoinTimeout(); - const timer = setTimeout(() => { - setIsCreating(false); - setIsJoining(false); - }, 0); - return () => clearTimeout(timer); - }, [lobby?.lobbyId, lobby?.inviteCode, lobby]); - - useEffect(() => { - if (!error) return; - clearCreateTimeout(); - clearJoinTimeout(); - const timer = setTimeout(() => { - setIsCreating(false); - setIsJoining(false); - }, 0); - return () => clearTimeout(timer); - }, [error]); - - useEffect(() => { - return () => { - clearCreateTimeout(); - clearJoinTimeout(); - }; - }, []); + reset(); + }, [lobby?.lobbyId, lobby?.inviteCode, lobby, reset]); return ( -
+
-
-
+
+
+ {/* The title reserves ~2 lines of height even when it's 1 line, so + the subtitle below starts at the same Y across both cards. */}

- {t("friend.startNewLobby")} {t("friend.lobbySuffix")} + + {t("friend.startNewLobby")} {t("friend.lobbySuffix")} +

-
+

-
-
+
+
+ {/* Same ~2-line min-height as the left card so the subtitle aligns. */}

- {t("friend.haveACode")} {t("friend.codeSuffix")} + + {t("friend.haveACode")} {t("friend.codeSuffix")} +

setInviteCode(e.target.value)} - maxLength={8} + maxLength={256} placeholder={t("friend.roomCodePlaceholder")} - className="h-16 w-full rounded-[20px] border-none bg-black/25 px-6 text-center text-white uppercase outline-none placeholder:text-white/40 focus:outline-none" + className="h-[88px] w-full rounded-[20px] border-none bg-black/25 px-6 text-center text-white uppercase outline-none placeholder:text-white/40 focus:outline-none" style={{ fontFamily: poppinsFont, fontWeight: 600, fontSize: 'clamp(18px, 2vw, 24px)', letterSpacing: '0.18em', + // letter-spacing adds a trailing gap after the last glyph; with + // text-center that pushes the final letter past the right edge and + // clips it. text-indent shifts the line right by one space to + // recenter and keep the last character fully visible. + textIndent: '0.18em', }} /> + +

+
+
+ ); + } + return (
@@ -223,12 +300,22 @@ export function FriendLobbyScreen({ roomCode, isHost }: FriendLobbyScreenProps)
diff --git a/src/features/friend/components/LobbyBrowsePanel.tsx b/src/features/friend/components/LobbyBrowsePanel.tsx index 4294d9fe..ca438275 100644 --- a/src/features/friend/components/LobbyBrowsePanel.tsx +++ b/src/features/friend/components/LobbyBrowsePanel.tsx @@ -3,6 +3,7 @@ import { Search, RotateCcw, Filter } from "lucide-react"; import { usePublicLobbies } from "@/lib/queries/lobbies.queries"; import { LobbyCard } from "./LobbyCard"; import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; import { useLocale } from "@/contexts/LocaleContext"; interface LobbyBrowsePanelProps { @@ -13,7 +14,7 @@ interface LobbyBrowsePanelProps { export function LobbyBrowsePanel({ onJoin, isJoiningCode, onActionTriggered }: LobbyBrowsePanelProps) { const { t } = useLocale(); - const { data: lobbies, isLoading, refetch } = usePublicLobbies(); + const { data: lobbies, isLoading, isFetching, refetch } = usePublicLobbies(); const [search, setSearch] = useState(""); const [filter, setFilter] = useState<'all' | 'open'>('open'); @@ -89,11 +90,13 @@ export function LobbyBrowsePanel({ onJoin, isJoiningCode, onActionTriggered }: L
@@ -107,14 +110,19 @@ export function LobbyBrowsePanel({ onJoin, isJoiningCode, onActionTriggered }: L
) : filteredLobbies.length === 0 ? (
-
+
-

{t("friend.noLobbiesFound")}

-

{t("friend.noLobbiesHint")}

+

+ {t("friend.noLobbiesFound")} +

+

{t("friend.noLobbiesHint")}

) : (
diff --git a/src/features/friend/components/LobbyCard.tsx b/src/features/friend/components/LobbyCard.tsx index 2b48f107..16e0bf99 100644 --- a/src/features/friend/components/LobbyCard.tsx +++ b/src/features/friend/components/LobbyCard.tsx @@ -1,11 +1,14 @@ +import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import type { PublicLobby } from "@/lib/domain/lobby"; -import { ArrowRight, Trophy, Users } from "lucide-react"; +import { ArrowRight, Loader2, Trophy, Users } from "lucide-react"; import { AvatarDisplay } from "@/components/AvatarDisplay"; +const poppins = { fontFamily: "'Poppins', sans-serif", fontWeight: 600 } as const; + interface LobbyCardProps { lobby: PublicLobby; onJoin: (inviteCode: string) => void; @@ -14,7 +17,23 @@ interface LobbyCardProps { export function LobbyCard({ lobby, onJoin, isJoining }: LobbyCardProps) { const isFull = lobby.memberCount >= lobby.maxMembers; - + + // Optimistic press: keep the spinner up for a minimum window so a fast + // join (instant socket ack + route) still shows feedback on tap. + const [pressed, setPressed] = useState(false); + const pressTimerRef = useRef | null>(null); + useEffect(() => () => { + if (pressTimerRef.current) clearTimeout(pressTimerRef.current); + }, []); + const joining = isJoining || pressed; + + const handleClick = () => { + if (isFull || joining) return; + setPressed(true); + pressTimerRef.current = setTimeout(() => setPressed(false), 800); + onJoin(lobby.inviteCode); + }; + return (
@@ -32,7 +51,7 @@ export function LobbyCard({ lobby, onJoin, isJoining }: LobbyCardProps) { {/* Info */}
- {lobby.displayName} + {lobby.displayName} {lobby.gameMode === 'ranked_sim' && ( Ranked @@ -54,17 +73,23 @@ export function LobbyCard({ lobby, onJoin, isJoining }: LobbyCardProps) { {/* Action */} + ); +} + export function LobbyHeader({ lobbyName, lobbyCode, @@ -28,6 +72,9 @@ export function LobbyHeader({ maxMembers, }: LobbyHeaderProps) { const { t } = useLocale(); + const inviteUrl = lobbyCode ? buildFriendInviteUrl(lobbyCode) : null; + const invitePath = lobbyCode ? buildFriendInvitePath(lobbyCode) : null; + const copyCode = async () => { if (!lobbyCode) return; const success = await copyToClipboard(lobbyCode); @@ -42,8 +89,6 @@ export function LobbyHeader({ }; const copyInviteLink = async () => { - if (!lobbyCode) return; - const inviteUrl = buildFriendInviteUrl(lobbyCode); if (!inviteUrl) return; const success = await copyToClipboard(inviteUrl); if (success) { @@ -73,35 +118,48 @@ export function LobbyHeader({ > {lobbyName || t("friend.friendlyLobby")} -
- - {t("appShell.code")} - - - {lobbyCode || "..."} - - - + +
+
+ + {t("appShell.code")} + + + {lobbyCode || "..."} + + void copyCode()} + disabled={!lobbyCode} + ariaLabel={t("friend.copyLobbyCode")} + /> +
+ +
+ + {t("friend.inviteLinkLabel")} + + + {invitePath || "..."} + + void copyInviteLink()} + disabled={!inviteUrl} + ariaLabel={t("friend.copyInviteLink")} + /> +
diff --git a/src/features/friend/components/LobbySettings.tsx b/src/features/friend/components/LobbySettings.tsx index c34297f0..02c2df86 100644 --- a/src/features/friend/components/LobbySettings.tsx +++ b/src/features/friend/components/LobbySettings.tsx @@ -437,17 +437,33 @@ export function LobbySettings({
{/* Mode Selector */}
- {t("friend.matchMode")} + + {t("friend.matchMode")} + {isPartyLocked ? (
-
{t("friend.partyQuiz")}
-
+
+ {t("friend.partyQuiz")} +
+
{t("friend.partyLockedHint")}
- + {memberCount}/6
@@ -457,8 +473,9 @@ export function LobbySettings({
)} -

+

{isPartyLocked ? t("friend.partyDescription") : mode === 'friendly_possession' @@ -505,7 +527,12 @@ export function LobbySettings({ {/* Lobby Visibility */}

- {t("friend.lobbyVisibility")} + + {t("friend.lobbyVisibility")} +
-
{isPublic ? t('friend.lobbyVisibilityPublic') : t('friend.lobbyVisibilityPrivate')}
-
+
+ {isPublic ? t('friend.lobbyVisibilityPublic') : t('friend.lobbyVisibilityPrivate')} +
+
{isPublic ? t('friend.lobbyVisibilityPublicHint') : t('friend.lobbyVisibilityPrivateHint')} @@ -545,7 +580,12 @@ export function LobbySettings({ {/* Categories (Friendly Only) */} {isFriendlyMode && (
- {t("friend.categoriesTitle")} + + {t("friend.categoriesTitle")} +
-
{t("friend.randomCategories")}
-
+
+ {t("friend.randomCategories")} +
+
{isRandom ? t("friend.randomCategoriesOn") : t("friend.randomCategoriesOff")}
@@ -581,7 +629,10 @@ export function LobbySettings({ {!isRandom && ( <> -

+

{mode === 'friendly_party_quiz' ? t("friend.pickCategoryParty") : t("friend.pickCategoryClassic")} @@ -599,7 +650,10 @@ export function LobbySettings({

{filteredCategories.length === 0 ? ( -

+

{t("friend.noCategoryMatchesSearch", { query: categorySearch })}

) : filteredCategories.map(cat => { @@ -609,8 +663,9 @@ export function LobbySettings({ key={cat.id} onClick={() => toggleCategory(cat.id)} disabled={!canEdit || isRandom} + style={{ fontFamily: "'Poppins', sans-serif", fontWeight: 600, letterSpacing: '0.02em' }} className={cn( - "w-full flex items-center gap-3 px-3 py-3.5 rounded-[14px] font-bold transition-colors border-2 bg-white/[0.04] hover:bg-white/[0.08]", + "w-full flex items-center gap-3 px-3 py-3.5 rounded-[14px] transition-colors border-2 bg-white/[0.04] hover:bg-white/[0.08]", isSelected ? "border-brand-green text-white" : "border-brand-blue text-white/70 hover:text-white", @@ -636,12 +691,20 @@ export function LobbySettings({ {/* Ranked Sim Info */} {mode === 'ranked_sim' && ( -
-
+
+
-

{t("friend.rankedSimHeader")}

-

+

+ {t("friend.rankedSimHeader")} +

+

{t("friend.rankedSimDescriptionLong")}

diff --git a/src/features/friend/components/__tests__/CreateJoinPanel.test.tsx b/src/features/friend/components/__tests__/CreateJoinPanel.test.tsx new file mode 100644 index 00000000..792eb9f1 --- /dev/null +++ b/src/features/friend/components/__tests__/CreateJoinPanel.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateJoinPanel } from "../CreateJoinPanel"; +import { useRealtimeMatchStore } from "@/stores/realtimeMatch.store"; + +const mocks = vi.hoisted(() => ({ + socketEmit: vi.fn(), + trackFriendInviteAccepted: vi.fn(), + toastInfo: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("@/contexts/LocaleContext", () => ({ + useLocale: () => ({ + t: (key: string, params?: Record) => + params ? `${key}|${JSON.stringify(params)}` : key, + }), +})); + +vi.mock("@/lib/realtime/socket-client", () => ({ + connectSocket: () => ({ emit: mocks.socketEmit }), + getSocket: () => ({ emit: mocks.socketEmit }), +})); + +vi.mock("@/lib/analytics/game-events", () => ({ + trackFriendInviteAccepted: (...args: unknown[]) => mocks.trackFriendInviteAccepted(...args), +})); + +vi.mock("sonner", () => ({ + toast: { + info: mocks.toastInfo, + error: mocks.toastError, + }, +})); + +describe("CreateJoinPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + useRealtimeMatchStore.getState().reset(); + mocks.socketEmit.mockImplementation((event: string, payload?: unknown, ack?: (result: unknown) => void) => { + if (typeof ack !== "function") return; + const correlationId = + payload && typeof payload === "object" && "correlationId" in payload + ? String((payload as { correlationId: unknown }).correlationId) + : "test-correlation"; + + if (event === "lobby:create") { + ack({ + ok: true, + lobbyId: "created-lobby", + inviteCode: "CRE8ED", + correlationId, + }); + } + + if (event === "lobby:join_by_code") { + ack({ + ok: true, + lobbyId: "joined-lobby", + inviteCode: + payload && typeof payload === "object" && "inviteCode" in payload + ? String((payload as { inviteCode: unknown }).inviteCode) + : "JOINED", + alreadyMember: false, + correlationId, + }); + } + }); + }); + + it("creates a lobby through the ack-driven command machine", () => { + const onActionTriggered = vi.fn(); + render(); + + fireEvent.click(screen.getByText("friend.createRoom")); + + expect(onActionTriggered).toHaveBeenCalledOnce(); + expect(mocks.socketEmit).toHaveBeenCalledWith("lobby:create", { + mode: "friendly", + isPublic: false, + correlationId: expect.any(String), + }, expect.any(Function)); + }); + + it("joins a manually entered code through the ack-driven command machine", () => { + const onActionTriggered = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText("friend.roomCodePlaceholder"), { + target: { value: "abc123" }, + }); + fireEvent.click(screen.getByText("friend.joinLobby")); + + expect(onActionTriggered).toHaveBeenCalledOnce(); + expect(mocks.trackFriendInviteAccepted).toHaveBeenCalledWith("ABC123"); + expect(mocks.socketEmit).toHaveBeenCalledWith("lobby:join_by_code", { + inviteCode: "ABC123", + correlationId: expect.any(String), + }, expect.any(Function)); + }); + + it("joins when a full invite link is pasted into the code field", () => { + const onActionTriggered = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText("friend.roomCodePlaceholder"), { + target: { value: "https://quizball.test/friend/room/abc123?from=share" }, + }); + fireEvent.click(screen.getByText("friend.joinLobby")); + + expect(onActionTriggered).toHaveBeenCalledOnce(); + expect(mocks.trackFriendInviteAccepted).toHaveBeenCalledWith("ABC123"); + expect(mocks.socketEmit).toHaveBeenCalledWith("lobby:join_by_code", { + inviteCode: "ABC123", + correlationId: expect.any(String), + }, expect.any(Function)); + }); +}); diff --git a/src/features/friend/hooks/__tests__/useFriendLobbyLogic.test.tsx b/src/features/friend/hooks/__tests__/useFriendLobbyLogic.test.tsx index eac1a40e..03b444e7 100644 --- a/src/features/friend/hooks/__tests__/useFriendLobbyLogic.test.tsx +++ b/src/features/friend/hooks/__tests__/useFriendLobbyLogic.test.tsx @@ -44,6 +44,7 @@ vi.mock('@/lib/realtime/useRealtimeConnection', () => ({ })); vi.mock('@/lib/realtime/socket-client', () => ({ + connectSocket: () => ({ emit: mocks.socketEmit }), getSocket: () => ({ emit: mocks.socketEmit }), })); @@ -103,6 +104,52 @@ function makeLobby(inviteCode: string): LobbyState { describe('useFriendLobbyLogic invite links', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.socketEmit.mockImplementation((event: string, payload?: unknown, ack?: (result: unknown) => void) => { + if (typeof ack !== 'function') return; + const correlationId = + payload && typeof payload === 'object' && 'correlationId' in payload + ? String((payload as { correlationId: unknown }).correlationId) + : 'test-correlation'; + if (event === 'lobby:create') { + ack({ + ok: true, + lobbyId: 'created-lobby', + inviteCode: 'CRE8ED', + correlationId, + }); + } + if (event === 'lobby:join_by_code') { + const inviteCode = + payload && typeof payload === 'object' && 'inviteCode' in payload + ? String((payload as { inviteCode: unknown }).inviteCode) + : 'JOINED'; + if (inviteCode === 'MISSING') { + ack({ + ok: false, + code: 'LOBBY_NOT_FOUND', + message: 'Lobby not found.', + retryable: false, + correlationId, + }); + return; + } + ack({ + ok: true, + lobbyId: 'joined-lobby', + inviteCode, + alreadyMember: false, + correlationId, + }); + } + if (event === 'lobby:leave') { + ack({ + ok: true, + lobbyId: 'left-lobby', + closed: false, + correlationId, + }); + } + }); vi.useRealTimers(); useRealtimeMatchStore.getState().reset(); }); @@ -111,6 +158,33 @@ describe('useFriendLobbyLogic invite links', () => { vi.useRealTimers(); }); + it('joins a concrete invite code instead of creating a lobby even if host query state is present', async () => { + renderHook(() => + useFriendLobbyLogic({ roomCode: 'NAYRR5', isHost: true }), + ); + + await waitFor(() => { + expect(mocks.socketEmit).toHaveBeenCalledWith('lobby:join_by_code', { + inviteCode: 'NAYRR5', + correlationId: expect.any(String), + }, expect.any(Function)); + }); + + expect(mocks.socketEmit).not.toHaveBeenCalledWith('lobby:create', expect.objectContaining({ mode: 'friendly' }), expect.any(Function)); + }); + + it('creates a lobby only for the new-room route', async () => { + renderHook(() => + useFriendLobbyLogic({ roomCode: 'new', isHost: true }), + ); + + expect(mocks.socketEmit).toHaveBeenCalledWith('lobby:create', { + mode: 'friendly', + correlationId: expect.any(String), + }, expect.any(Function)); + expect(mocks.socketEmit).not.toHaveBeenCalledWith('lobby:join_by_code', expect.anything(), expect.any(Function)); + }); + it('does not expose a stale lobby when the URL invite code points to another room', async () => { act(() => { useRealtimeMatchStore.getState().setLobby(makeLobby('N3K5UZ')); @@ -123,6 +197,7 @@ describe('useFriendLobbyLogic invite links', () => { await waitFor(() => { expect(mocks.socketEmit).toHaveBeenCalledWith('lobby:join_by_code', { inviteCode: 'NAYRR5', + correlationId: expect.any(String), }, expect.any(Function)); }); @@ -133,6 +208,53 @@ describe('useFriendLobbyLogic invite links', () => { expect(mocks.startSession).not.toHaveBeenCalled(); }); + it('stops resolving and exposes a terminal invite failure when the lobby is gone', async () => { + const { result } = renderHook(() => + useFriendLobbyLogic({ roomCode: 'MISSING', isHost: false }), + ); + + await waitFor(() => { + expect(mocks.socketEmit).toHaveBeenCalledWith('lobby:join_by_code', { + inviteCode: 'MISSING', + correlationId: expect.any(String), + }, expect.any(Function)); + }); + + await waitFor(() => { + expect(result.current.inviteJoinFailure).toEqual({ + code: 'MISSING', + message: 'Lobby not found.', + }); + }); + + expect(result.current.isResolvingInvite).toBe(false); + expect(mocks.toastError).toHaveBeenCalledWith('Lobby not found.'); + }); + + it('does not try to rejoin the invite while the lobby is handing off to an active match', async () => { + act(() => { + useRealtimeMatchStore.getState().setSessionState({ + state: 'IN_ACTIVE_MATCH', + activeMatchId: 'match-1', + waitingLobbyId: null, + queueSearchId: null, + openLobbyIds: [], + resolvedAt: new Date().toISOString(), + }); + }); + + const { result } = renderHook(() => + useFriendLobbyLogic({ roomCode: 'NAYRR5', isHost: true }), + ); + + await Promise.resolve(); + + expect(result.current.isPreparingMatch).toBe(true); + expect(result.current.isResolvingInvite).toBe(false); + expect(result.current.inviteJoinFailure).toBeNull(); + expect(mocks.socketEmit).not.toHaveBeenCalledWith('lobby:join_by_code', expect.anything(), expect.any(Function)); + }); + it('does not spam invite joins or toasts on transient transition locks', async () => { act(() => { useRealtimeMatchStore.getState().setLobby(makeLobby('N3K5UZ')); @@ -145,6 +267,7 @@ describe('useFriendLobbyLogic invite links', () => { await waitFor(() => { expect(mocks.socketEmit).toHaveBeenCalledWith('lobby:join_by_code', { inviteCode: 'NAYRR5', + correlationId: expect.any(String), }, expect.any(Function)); }); @@ -165,7 +288,6 @@ describe('useFriendLobbyLogic invite links', () => { }); it('cancels pending invite retries when the user leaves from the resolving state', async () => { - vi.useFakeTimers(); act(() => { useRealtimeMatchStore.getState().setLobby(makeLobby('N3K5UZ')); }); @@ -176,17 +298,21 @@ describe('useFriendLobbyLogic invite links', () => { expect(mocks.socketEmit).toHaveBeenCalledWith('lobby:join_by_code', { inviteCode: 'NAYRR5', + correlationId: expect.any(String), }, expect.any(Function)); - act(() => { + await act(async () => { result.current.actions.handleLeaveLobby(); - vi.advanceTimersByTime(3000); }); const joinCalls = mocks.socketEmit.mock.calls.filter(([event]) => event === 'lobby:join_by_code'); expect(joinCalls).toHaveLength(1); - expect(mocks.socketEmit).toHaveBeenCalledWith('lobby:leave'); - expect(mocks.routerReplace).toHaveBeenCalledWith('/play'); + expect(mocks.socketEmit).toHaveBeenCalledWith('lobby:leave', { + correlationId: expect.any(String), + }, expect.any(Function)); + await waitFor(() => { + expect(mocks.routerReplace).toHaveBeenCalledWith('/play'); + }); }); it('exposes the lobby only after it matches the URL invite code', async () => { @@ -205,6 +331,7 @@ describe('useFriendLobbyLogic invite links', () => { expect(result.current.isResolvingInvite).toBe(false); expect(mocks.socketEmit).not.toHaveBeenCalledWith('lobby:join_by_code', { inviteCode: 'NAYRR5', + correlationId: expect.any(String), }, expect.any(Function)); await waitFor(() => { expect(mocks.startSession).toHaveBeenCalledWith({ diff --git a/src/features/friend/hooks/useFriendLobbyLogic.ts b/src/features/friend/hooks/useFriendLobbyLogic.ts index 82415ab2..db6777d1 100644 --- a/src/features/friend/hooks/useFriendLobbyLogic.ts +++ b/src/features/friend/hooks/useFriendLobbyLogic.ts @@ -10,15 +10,14 @@ import { usePlayer } from "@/contexts/PlayerContext"; import { useAuthStore } from "@/stores/auth.store"; import { useGameSessionStore } from "@/stores/gameSession.store"; import { logger } from "@/utils/logger"; -import type { - LobbyJoinByCodeResult, - LobbySettings as LobbySettingsState, -} from "@/lib/realtime/socket.types"; +import type { LobbySettings as LobbySettingsState } from "@/lib/realtime/socket.types"; import { useCategoriesList } from "@/lib/queries/categories.queries"; import { copyToClipboard } from "@/utils/clipboard"; import { trackFriendInviteSent } from "@/lib/analytics/game-events"; import { useHeadToHead } from "@/lib/queries/stats.queries"; import { trackLobbyCreated, trackLobbyJoined } from "@/lib/analytics/game-events"; +import { normalizeFriendInviteCode } from "@/lib/friend/inviteCode"; +import { useLobbyCommandMachine } from "./useLobbyCommandMachine"; interface UseFriendLobbyLogicProps { roomCode: string; @@ -36,6 +35,7 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro const lobby = useRealtimeMatchStore((state) => state.lobby); const draft = useRealtimeMatchStore((state) => state.draft); const hasActiveMatch = useRealtimeMatchStore((s) => s.match != null); + const sessionState = useRealtimeMatchStore((state) => state.sessionState); const error = useRealtimeMatchStore((state) => state.error); const clearError = useRealtimeMatchStore((state) => state.clearError); const pendingLobbyHandoffCode = useRealtimeMatchStore((state) => state.pendingLobbyHandoffCode); @@ -52,6 +52,13 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro // Connection useRealtimeConnection({ enabled: true, selfUserId }); + const lobbyCommands = useLobbyCommandMachine(); + const { + createLobby, + joinByCode, + leaveLobby, + reset: resetLobbyCommand, + } = lobbyCommands; const startedRef = useRef(false); const createdRef = useRef(false); @@ -67,8 +74,11 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro const [settingsErrorVersion, setSettingsErrorVersion] = useState(0); const [isStartingMatch, setIsStartingMatch] = useState(false); const [handoffTimedOutCode, setHandoffTimedOutCode] = useState(null); - const [joinRetryNonce, setJoinRetryNonce] = useState(0); const [optimisticReady, setOptimisticReady] = useState(null); + const [inviteJoinFailureState, setInviteJoinFailure] = useState<{ + code: string; + message: string; + } | null>(null); const clearStartMatchTimeout = useCallback(() => { if (!startMatchTimeoutRef.current) return; @@ -76,11 +86,21 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro startMatchTimeoutRef.current = null; }, []); - const normalizedRoomCode = roomCode && roomCode !== "new" ? roomCode.toUpperCase() : null; - const expectsInviteLobby = Boolean(normalizedRoomCode && !isHost); + const isNewRoomRoute = roomCode.trim().toLowerCase() === "new"; + const shouldCreateLobby = isHost && isNewRoomRoute; + const normalizedRoomCode = roomCode && !isNewRoomRoute ? normalizeFriendInviteCode(roomCode) : null; + const inviteJoinFailure = + inviteJoinFailureState?.code === normalizedRoomCode ? inviteJoinFailureState : null; + const expectsInviteLobby = Boolean(normalizedRoomCode); const lobbyMatchesInvite = !expectsInviteLobby || lobby?.inviteCode?.toUpperCase() === normalizedRoomCode; const activeLobby = lobbyMatchesInvite ? lobby : null; - const isResolvingInvite = expectsInviteLobby && !activeLobby; + const isActiveMatchHandoff = + expectsInviteLobby && + (hasActiveMatch || + Boolean(draft) || + (sessionState?.state === "IN_ACTIVE_MATCH" && Boolean(sessionState.activeMatchId))); + const isPreparingMatch = Boolean(isStartingMatch || activeLobby?.status === "active" || isActiveMatchHandoff); + const isResolvingInvite = expectsInviteLobby && !activeLobby && !inviteJoinFailure && !isPreparingMatch; const lobbyCode = activeLobby?.inviteCode ?? (roomCode === "new" ? "" : normalizedRoomCode ?? roomCode); const members = activeLobby?.members ?? []; const me = members.find((member) => member.userId === selfUserId); @@ -100,18 +120,20 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro // 1. Reset local guards after leaving a lobby/match useEffect(() => { if (isResolvingInvite) return; + if (isPreparingMatch) return; if (activeLobby || draft || hasActiveMatch) return; if (leavingRef.current) return; startedRef.current = false; createdRef.current = false; analyticsTrackedRef.current = false; initActionRef.current = null; + resetLobbyCommand(); clearStartMatchTimeout(); const stopTimer = setTimeout(() => { setIsStartingMatch(false); }, 0); return () => clearTimeout(stopTimer); - }, [clearStartMatchTimeout, activeLobby, draft, hasActiveMatch, isResolvingInvite]); + }, [clearStartMatchTimeout, activeLobby, draft, hasActiveMatch, isPreparingMatch, isResolvingInvite, resetLobbyCommand]); useEffect(() => { if (!normalizedRoomCode || pendingLobbyHandoffCode !== normalizedRoomCode) return; @@ -119,7 +141,6 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro if (lobby?.inviteCode?.toUpperCase() === normalizedRoomCode) { clearLobbyHandoff(); queueMicrotask(() => setHandoffTimedOutCode(null)); - queueMicrotask(() => setJoinRetryNonce(0)); return; } @@ -129,40 +150,32 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro return () => clearTimeout(timer); }, [clearLobbyHandoff, lobby?.inviteCode, normalizedRoomCode, pendingLobbyHandoffCode]); - useEffect(() => { - if (!isResolvingInvite || !normalizedRoomCode) return; - if (terminalInviteJoinFailureRef.current) return; - if (joinRetryNonce >= 3) return; - - const timer = setTimeout(() => { - if (leavingRef.current || inviteJoinCancelledRef.current) return; - initActionRef.current = null; - createdRef.current = false; - setJoinRetryNonce((current) => current + 1); - }, 2500); - return () => clearTimeout(timer); - }, [isResolvingInvite, joinRetryNonce, normalizedRoomCode]); - // 2. Socket Initialization useEffect(() => { if (leavingRef.current) return; if (inviteJoinCancelledRef.current) return; if (terminalInviteJoinFailureRef.current) return; + if (inviteJoinFailure) return; + if (isPreparingMatch || isActiveMatchHandoff || hasActiveMatch || draft) return; if (createdRef.current) return; - const socket = getSocket(); const targetCode = normalizedRoomCode; const currentCode = lobby?.inviteCode?.toUpperCase() ?? null; - if (isHost) { + if (shouldCreateLobby) { if (initActionRef.current === "create") return; initActionRef.current = "create"; createdRef.current = true; - socket.emit("lobby:create", { mode: "friendly" }); - logger.info("Socket emit lobby:create", { mode: "friendly" }); + void createLobby({ mode: "friendly" }).then((result) => { + if (!result || result.ok || leavingRef.current || inviteJoinCancelledRef.current) return; + createdRef.current = false; + initActionRef.current = null; + toast.error(result.message); + }); + logger.info("Socket emit lobby:create via command machine", { mode: "friendly" }); return; } - if (!roomCode || roomCode === "new") return; + if (!roomCode || isNewRoomRoute) return; if (currentCode && currentCode === targetCode) return; if ( targetCode && @@ -179,29 +192,63 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro if (initActionRef.current === joinKey) return; initActionRef.current = joinKey; createdRef.current = true; - socket.emit("lobby:join_by_code", { inviteCode: roomCode }, (result: LobbyJoinByCodeResult) => { + void joinByCode(roomCode).then((result) => { if (leavingRef.current || inviteJoinCancelledRef.current) return; - if (result.ok || result.retryable) return; + if (!result || result.ok) return; + const latestState = useRealtimeMatchStore.getState(); + const latestHasMatchHandoff = + latestState.match !== null || + latestState.draft !== null || + (latestState.sessionState?.state === "IN_ACTIVE_MATCH" && Boolean(latestState.sessionState.activeMatchId)); + if (latestHasMatchHandoff) { + logger.info("Ignoring invite join failure during match handoff", { + inviteCode: targetCode ? `${targetCode.slice(0, 2)}***` : null, + code: result.code, + sessionState: latestState.sessionState?.state ?? null, + activeMatchId: latestState.sessionState?.activeMatchId ?? null, + }); + return; + } terminalInviteJoinFailureRef.current = true; inviteJoinCancelledRef.current = true; - createdRef.current = true; + setInviteJoinFailure({ + code: targetCode ?? roomCode.toUpperCase(), + message: result.message, + }); + toast.error(result.message); }); - logger.info("Socket emit lobby:join_by_code", { + logger.info("Socket emit lobby:join_by_code via command machine", { inviteCode: `${roomCode.slice(0, 2)}***`, }); - }, [handoffTimedOutCode, isHost, joinRetryNonce, lobby?.inviteCode, lobby, normalizedRoomCode, pendingLobbyHandoffCode, roomCode]); + }, [ + createLobby, + handoffTimedOutCode, + hasActiveMatch, + isActiveMatchHandoff, + isNewRoomRoute, + isPreparingMatch, + inviteJoinFailure, + joinByCode, + lobby?.inviteCode, + lobby, + normalizedRoomCode, + pendingLobbyHandoffCode, + roomCode, + shouldCreateLobby, + draft, + ]); // 2.5. Track lobby creation/join success when lobby is confirmed useEffect(() => { if (!activeLobby || analyticsTrackedRef.current) return; analyticsTrackedRef.current = true; - if (isHost) { + if (shouldCreateLobby) { trackLobbyCreated("friendly"); } else { trackLobbyJoined(activeLobby.lobbyId, activeLobby.inviteCode ?? roomCode); } - }, [activeLobby, isHost, roomCode]); + }, [activeLobby, roomCode, shouldCreateLobby]); // 3. Navigation & Session Logic useEffect(() => { @@ -241,7 +288,7 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro } prevOpponentIdRef.current = currentOpponentId; - }, [activeLobby, opponent?.userId]); + }, [activeLobby, opponent?.userId, t]); useEffect(() => { if (!activeLobby) return; @@ -273,7 +320,14 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro error.code === "TRANSITION_IN_PROGRESS"; const isTransientSettingsBusy = error.code === "LOBBY_SETTINGS_LOCKED"; const isInviteTransitionBusy = isResolvingInvite && error.code === "TRANSITION_IN_PROGRESS"; + const isMatchHandoffJoinError = + isPreparingMatch && + (error.code === "ALREADY_IN_LOBBY" || error.code === "ACTIVE_MATCH"); + if (isMatchHandoffJoinError) { + clearError(); + return; + } if (isLobbySettingsError && !isInviteTransitionBusy) { timer = setTimeout(() => { setSettingsErrorVersion((current) => current + 1); @@ -294,7 +348,7 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro } clearTimeout(stopStartingTimer); }; - }, [clearError, clearStartMatchTimeout, error, isResolvingInvite]); + }, [clearError, clearStartMatchTimeout, error, isPreparingMatch, isResolvingInvite]); // 3. Actions const copyCode = async () => { @@ -352,6 +406,8 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro const handleStartMatch = () => { if (isStartingMatch) return; + inviteJoinCancelledRef.current = true; + terminalInviteJoinFailureRef.current = true; setIsStartingMatch(true); clearStartMatchTimeout(); startMatchTimeoutRef.current = setTimeout(() => { @@ -373,15 +429,46 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro initActionRef.current = null; clearStartMatchTimeout(); setIsStartingMatch(false); - getSocket().emit("lobby:leave"); - logger.info("Socket emit lobby:leave"); - // Leave room route immediately to avoid URL-driven auto-rejoin. - router.replace("/play"); - useRankedMatchmakingStore.getState().clearRankedMatchmaking(); - window.setTimeout(() => { + void leaveLobby().then((result) => { + if (!result) return; + if (!result.ok) { + leavingRef.current = false; + toast.error(result.message); + return; + } + logger.info("Socket ack lobby:leave", { + lobbyId: result.lobbyId, + closed: result.closed, + correlationId: result.correlationId, + }); + // Leave room route after server ack so URL-driven rejoin cannot race the backend removal. + router.replace("/play"); + useRankedMatchmakingStore.getState().clearRankedMatchmaking(); useRealtimeMatchStore.getState().reset(); + resetLobbyCommand(); leavingRef.current = false; - }, 150); + }); + logger.info("Socket emit lobby:leave via command machine"); + }; + + const handleInviteRetry = () => { + if (!normalizedRoomCode) return; + inviteJoinCancelledRef.current = false; + terminalInviteJoinFailureRef.current = false; + createdRef.current = false; + initActionRef.current = null; + setInviteJoinFailure(null); + resetLobbyCommand(); + }; + + const handleInviteBack = () => { + inviteJoinCancelledRef.current = true; + terminalInviteJoinFailureRef.current = true; + createdRef.current = true; + initActionRef.current = null; + setInviteJoinFailure(null); + resetLobbyCommand(); + router.replace("/play/friend?tab=create"); }; useEffect(() => { @@ -399,6 +486,8 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro members, lobbyCode, isResolvingInvite, + isPreparingMatch, + inviteJoinFailure, targetInviteCode: normalizedRoomCode, me, opponent, @@ -406,6 +495,7 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro allCategories, settingsErrorVersion, isStartingMatch, + isLeaving: lobbyCommands.isLeaving, optimisticReady: derivedOptimisticReady, actions: { copyCode, @@ -413,6 +503,8 @@ export function useFriendLobbyLogic({ roomCode, isHost }: UseFriendLobbyLogicPro handleUpdateSettings, handleStartMatch, handleLeaveLobby, + handleInviteRetry, + handleInviteBack, }, }; } diff --git a/src/features/friend/hooks/useLobbyCommandMachine.ts b/src/features/friend/hooks/useLobbyCommandMachine.ts new file mode 100644 index 00000000..50ced23e --- /dev/null +++ b/src/features/friend/hooks/useLobbyCommandMachine.ts @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { connectSocket, getSocket } from "@/lib/realtime/socket-client"; +import type { + LobbyCreateResult, + LobbyJoinByCodeResult, + LobbyLeaveResult, + MatchMode, +} from "@/lib/realtime/socket.types"; +import { extractFriendInviteCode } from "@/lib/friend/inviteCode"; +import { logger } from "@/utils/logger"; + +type LobbyCommandOperation = "create" | "join" | "leave"; +type LobbyCommandStatus = "idle" | "creating" | "joining" | "leaving" | "success" | "failed"; + +export interface LobbyCommandError { + ok: false; + code: string; + message: string; + retryable: boolean; + correlationId: string; +} + +export type LobbyCommandOutcome = + | LobbyCreateResult + | LobbyJoinByCodeResult + | LobbyLeaveResult + | LobbyCommandError; + +interface LobbyCommandState { + status: LobbyCommandStatus; + operation: LobbyCommandOperation | null; + commandKey: string | null; + correlationId: string | null; + targetInviteCode: string | null; + error: LobbyCommandError | null; +} + +interface ExecuteOptions { + operation: LobbyCommandOperation; + commandKey: string; + targetInviteCode?: string | null; + maxRetries?: number; + send: (correlationId: string) => Promise; +} + +const COMMAND_ACK_TIMEOUT_MS = 10_000; +const INITIAL_STATE: LobbyCommandState = { + status: "idle", + operation: null, + commandKey: null, + correlationId: null, + targetInviteCode: null, + error: null, +}; + +function operationStatus(operation: LobbyCommandOperation): LobbyCommandStatus { + if (operation === "create") return "creating"; + if (operation === "join") return "joining"; + return "leaving"; +} + +function createCorrelationId(operation: LobbyCommandOperation): string { + const randomId = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(36).slice(2)}`; + return `lobby_${operation}_${randomId}`; +} + +function isActiveOrComplete(state: LobbyCommandState): boolean { + return ( + state.status === "creating" || + state.status === "joining" || + state.status === "leaving" || + state.status === "success" + ); +} + +export function useLobbyCommandMachine() { + const [state, setState] = useState(INITIAL_STATE); + const stateRef = useRef(state); + const sequenceRef = useRef(0); + const mountedRef = useRef(true); + const timersRef = useRef(new Set>()); + + const setMachineState = useCallback((nextState: LobbyCommandState) => { + stateRef.current = nextState; + if (mountedRef.current) { + setState(nextState); + } + }, []); + + const wait = useCallback((ms: number) => { + return new Promise((resolve) => { + const timer = setTimeout(() => { + timersRef.current.delete(timer); + resolve(); + }, ms); + timersRef.current.add(timer); + }); + }, []); + + useEffect(() => { + const timers = timersRef.current; + return () => { + mountedRef.current = false; + timers.forEach((timer) => clearTimeout(timer)); + timers.clear(); + }; + }, []); + + const emitWithAck = useCallback(( + emit: (correlationId: string, ack: (result: T) => void) => void, + correlationId: string + ): Promise => { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + timersRef.current.delete(timeout); + resolve({ + ok: false, + code: "LOBBY_ACK_TIMEOUT", + message: "Lobby command timed out. Please try again.", + retryable: false, + correlationId, + }); + }, COMMAND_ACK_TIMEOUT_MS); + timersRef.current.add(timeout); + + try { + connectSocket(); + emit(correlationId, (result) => { + clearTimeout(timeout); + timersRef.current.delete(timeout); + resolve(result); + }); + } catch (error) { + clearTimeout(timeout); + timersRef.current.delete(timeout); + logger.error("Lobby command emit failed", { error, correlationId }); + resolve({ + ok: false, + code: "LOBBY_EMIT_ERROR", + message: "Could not send lobby command. Please try again.", + retryable: false, + correlationId, + }); + } + }); + }, []); + + const execute = useCallback(async ({ + operation, + commandKey, + targetInviteCode = null, + maxRetries = 4, + send, + }: ExecuteOptions): Promise => { + const current = stateRef.current; + if (current.commandKey === commandKey && isActiveOrComplete(current)) { + logger.info("Lobby command skipped because it is already active", { + operation, + commandKey, + correlationId: current.correlationId, + }); + return null; + } + + const sequence = sequenceRef.current + 1; + sequenceRef.current = sequence; + const correlationId = createCorrelationId(operation); + + setMachineState({ + status: operationStatus(operation), + operation, + commandKey, + correlationId, + targetInviteCode, + error: null, + }); + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const result = await send(correlationId); + if (sequenceRef.current !== sequence) return null; + + logger.info("Lobby command ack received", { + operation, + commandKey, + correlationId, + ok: result.ok, + code: result.ok ? null : result.code, + retryable: result.ok ? false : result.retryable, + attempt, + }); + + if (result.ok) { + setMachineState({ + status: "success", + operation, + commandKey, + correlationId, + targetInviteCode, + error: null, + }); + return result; + } + + if (result.retryable && attempt < maxRetries) { + await wait(220 + attempt * 140); + continue; + } + + setMachineState({ + status: "failed", + operation, + commandKey, + correlationId, + targetInviteCode, + error: result, + }); + return result; + } + + return null; + }, [setMachineState, wait]); + + const createLobby = useCallback((payload: { mode: MatchMode; isPublic?: boolean }) => { + const commandKey = `create:${payload.mode}:${payload.isPublic === true ? "public" : "private"}`; + return execute({ + operation: "create", + commandKey, + maxRetries: 2, + send: (correlationId) => + emitWithAck( + (id, ack) => { + getSocket().emit("lobby:create", { ...payload, correlationId: id }, ack); + }, + correlationId + ), + }); + }, [emitWithAck, execute]); + + const joinByCode = useCallback((inviteCode: string) => { + const targetInviteCode = extractFriendInviteCode(inviteCode); + if (!targetInviteCode) { + const correlationId = createCorrelationId("join"); + const error: LobbyCommandError = { + ok: false, + code: "INVALID_INVITE_CODE", + message: "Please enter a valid lobby code or invite link.", + retryable: false, + correlationId, + }; + setMachineState({ + status: "failed", + operation: "join", + commandKey: "join:invalid", + correlationId, + targetInviteCode: null, + error, + }); + return Promise.resolve(error); + } + + return execute({ + operation: "join", + commandKey: `join:${targetInviteCode}`, + targetInviteCode, + maxRetries: 5, + send: (correlationId) => + emitWithAck( + (id, ack) => { + getSocket().emit("lobby:join_by_code", { inviteCode: targetInviteCode, correlationId: id }, ack); + }, + correlationId + ), + }); + }, [emitWithAck, execute, setMachineState]); + + const leaveLobby = useCallback(() => { + return execute({ + operation: "leave", + commandKey: "leave", + maxRetries: 3, + send: (correlationId) => + emitWithAck( + (id, ack) => { + getSocket().emit("lobby:leave", { correlationId: id }, ack); + }, + correlationId + ), + }); + }, [emitWithAck, execute]); + + const reset = useCallback(() => { + sequenceRef.current += 1; + setMachineState(INITIAL_STATE); + }, [setMachineState]); + + return { + state, + isCreating: state.status === "creating", + isJoining: state.status === "joining", + isLeaving: state.status === "leaving", + isBusy: state.status === "creating" || state.status === "joining" || state.status === "leaving", + createLobby, + joinByCode, + leaveLobby, + reset, + }; +} diff --git a/src/features/game/GameStageRouter.tsx b/src/features/game/GameStageRouter.tsx index f27f0636..8933a124 100644 --- a/src/features/game/GameStageRouter.tsx +++ b/src/features/game/GameStageRouter.tsx @@ -19,6 +19,12 @@ import { tierFromRp } from "@/utils/rankedTier"; import { parseRp } from "@/lib/utils"; import { TrainingMatchScreen } from "@/features/training/TrainingMatchScreen"; import { useGameStageState } from "@/features/game/hooks/useGameStageState"; +import { + markExitToPlayPending, + trackExitToPlayStarted, + trackResultsMainMenuClicked, + type ExitToPlaySource, +} from "@/lib/analytics/game-events"; const POSSESSION_TOTAL_QUESTIONS_FALLBACK = 12; @@ -76,8 +82,25 @@ export function GameStageRouter() { const showdownType = matchType === "ranked" ? "ranked" : "friendly"; - const exitToPlay = useCallback(() => { + const exitToPlay = useCallback((source: ExitToPlaySource = "generic") => { const final = realtimeMatch.finalResults; + const finalResultVersion = typeof final?.resultVersion === "number" ? final.resultVersion : null; + const exitAnalytics = { + source, + matchId: final?.matchId ?? realtimeMatch.matchId ?? null, + matchType, + mode: (realtimeMatch as { mode?: string | null }).mode ?? config?.mode ?? matchType, + variant: realtimeMatch.variant ?? config?.matchType ?? null, + resultVersion: finalResultVersion, + hadFinalResults: Boolean(final), + finalResultsAckSent: Boolean(final), + stage, + }; + if (source === "results_main_menu" || source === "party_results_main_menu") { + trackResultsMainMenuClicked(exitAnalytics); + } + trackExitToPlayStarted(exitAnalytics); + markExitToPlayPending(exitAnalytics); if (final) { socket.emit("match:final_results_ack", { matchId: final.matchId, @@ -92,7 +115,18 @@ export function GameStageRouter() { clearRankedMatchmaking(); resetGameSession(); router.push("/play"); - }, [clearRankedMatchmaking, realtimeMatch.finalResults, resetGameSession, resetRealtime, router, socket]); + }, [ + clearRankedMatchmaking, + config?.matchType, + config?.mode, + matchType, + realtimeMatch, + resetGameSession, + resetRealtime, + router, + socket, + stage, + ]); useEffect(() => { const inviteCode = realtimeLobby?.inviteCode; @@ -141,7 +175,7 @@ export function GameStageRouter() { } else { logger.info("Socket emit match:leave skipped (missing matchId)"); } - exitToPlay(); + exitToPlay("match_quit"); }, [realtimeMatchId, exitToPlay]); const handleForfeit = useCallback(() => { @@ -168,7 +202,7 @@ export function GameStageRouter() { getSocket().emit("lobby:leave"); logger.info("Socket emit lobby:leave"); } - exitToPlay(); + exitToPlay("matchmaking_exit"); }, [exitToPlay, matchType]); const matchmakingDebugInfo = useMemo( @@ -203,7 +237,7 @@ export function GameStageRouter() { ); if (config?.mode === "training") { - return ; + return exitToPlay("training_complete")} />; } if (stage === "idle" && !showingFinalResultsFromReplay) { @@ -304,7 +338,7 @@ export function GameStageRouter() { logger.info("Socket emit match:play_again", { matchId: realtimeMatch.matchId }); }} onMainMenu={() => { - exitToPlay(); + exitToPlay("party_results_main_menu"); }} /> ); @@ -407,7 +441,7 @@ export function GameStageRouter() { logger.info("Socket emit match:play_again", { matchId: realtimeMatch.matchId }); }} onMainMenu={() => { - exitToPlay(); + exitToPlay("results_main_menu"); }} /> ); diff --git a/src/features/game/__tests__/GameStageRouter.test.tsx b/src/features/game/__tests__/GameStageRouter.test.tsx index 3482d16a..ae0c7e8b 100644 --- a/src/features/game/__tests__/GameStageRouter.test.tsx +++ b/src/features/game/__tests__/GameStageRouter.test.tsx @@ -23,9 +23,15 @@ type RealtimeResultsScreenMockProps = { opponentRankPoints?: number | null; playerQuestionResults?: Array<'correct' | 'wrong' | null>; opponentQuestionResults?: Array<'correct' | 'wrong' | null>; + onMainMenu?: () => void; }; const realtimeResultsRenderProps = vi.hoisted(() => [] as RealtimeResultsScreenMockProps[]); +const analyticsMocks = vi.hoisted(() => ({ + trackResultsMainMenuClicked: vi.fn(), + trackExitToPlayStarted: vi.fn(), + markExitToPlayPending: vi.fn(), +})); function createInitialGameSessionState() { return { @@ -147,6 +153,8 @@ vi.mock('@/lib/match/useGameStageTransitions', () => ({ useGameStageTransitions: () => {}, })); +vi.mock('@/lib/analytics/game-events', () => analyticsMocks); + vi.mock('@/lib/queries/ranked.queries', () => ({ useRankedProfile: () => ({ data: rankedProfileData }), })); @@ -179,7 +187,12 @@ vi.mock('@/features/play/RankedCategoryBlockingScreen', () => ({ vi.mock('@/features/game/RealtimeResultsScreen', () => ({ RealtimeResultsScreen: (props: RealtimeResultsScreenMockProps) => { realtimeResultsRenderProps.push(props); - return
Realtime Results {String(props.finalWinnerId)}
; + return ( +
+
Realtime Results {String(props.finalWinnerId)}
+ +
+ ); }, })); @@ -197,7 +210,12 @@ vi.mock('@/features/party/RealtimePartyQuizScreen', () => ({ })); vi.mock('@/features/party/PartyQuizResultsScreen', () => ({ - PartyQuizResultsScreen: () =>
Party Quiz Results
, + PartyQuizResultsScreen: (props: { onMainMenu: () => void }) => ( +
+
Party Quiz Results
+ +
+ ), })); vi.mock('@/components/shared/LoadingScreen', () => ({ @@ -343,6 +361,63 @@ describe('GameStageRouter', () => { expect(screen.getByText('Realtime Results self-1')).toBeInTheDocument(); }); + it('tracks results main-menu exits and marks the /play landing pending', () => { + gameSessionState.stage = 'finalResults'; + gameSessionState.config = { + mode: 'ranked', + matchType: 'ranked', + categoryName: 'Football', + categoryIcon: '⚽', + }; + realtimeMatchState.match = { + ...realtimeMatchState.match, + mode: 'ranked', + variant: 'ranked_sim', + finalResults: { + matchId: 'match-results-1', + resultVersion: 42, + winnerId: 'self-1', + winnerDecisionMethod: 'goals', + players: { + 'self-1': { userId: 'self-1', goals: 2, correctAnswers: 9 }, + 'opp-1': { userId: 'opp-1', goals: 1, correctAnswers: 7 }, + }, + unlockedAchievements: {}, + rankedOutcome: null, + }, + } as unknown as typeof realtimeMatchState.match & { + mode: 'ranked'; + finalResults: unknown; + }; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Main Menu' })); + + const expectedAnalytics = expect.objectContaining({ + source: 'results_main_menu', + matchId: 'match-results-1', + matchType: 'ranked', + mode: 'ranked', + variant: 'ranked_sim', + resultVersion: 42, + hadFinalResults: true, + finalResultsAckSent: true, + stage: 'finalResults', + }); + expect(analyticsMocks.trackResultsMainMenuClicked).toHaveBeenCalledWith(expectedAnalytics); + expect(analyticsMocks.trackExitToPlayStarted).toHaveBeenCalledWith(expectedAnalytics); + expect(analyticsMocks.markExitToPlayPending).toHaveBeenCalledWith(expectedAnalytics); + expect(socket.emit).toHaveBeenCalledWith('match:final_results_ack', { + matchId: 'match-results-1', + resultVersion: 42, + }); + expect(realtimeMatchState.reset).toHaveBeenCalled(); + expect(rankedMatchmakingState.clearRankedMatchmaking).toHaveBeenCalled(); + expect(gameSessionState.reset).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalledWith('/play'); + }); + it('renders replayed final results when a reload clears the game session', () => { gameSessionState.stage = 'idle'; gameSessionState.config = null as never; diff --git a/src/features/leaderboard/LeaderboardScreen.tsx b/src/features/leaderboard/LeaderboardScreen.tsx index 60910026..fd8f8a25 100644 --- a/src/features/leaderboard/LeaderboardScreen.tsx +++ b/src/features/leaderboard/LeaderboardScreen.tsx @@ -68,7 +68,7 @@ export function LeaderboardScreen({ currentPlayerId }: LeaderboardScreenProps) { > {t("leaderboard.title")} -

+

{t("leaderboard.subtitle")}

diff --git a/src/features/play/ModeSelectionScreen.tsx b/src/features/play/ModeSelectionScreen.tsx index bb0473db..e596dac2 100644 --- a/src/features/play/ModeSelectionScreen.tsx +++ b/src/features/play/ModeSelectionScreen.tsx @@ -1,5 +1,5 @@ import { cn } from '@/lib/utils'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { motion } from 'motion/react'; import Image from 'next/image'; import Link from 'next/link'; @@ -17,6 +17,21 @@ import { colors } from '@/lib/colors'; import { getNextTierBand } from '@/utils/rankedTier'; +const PLAY_ENTRANCE_SESSION_KEY = 'quizball.playEntranceSeen'; +const PLAY_ENTRANCE_INITIAL = { opacity: 0.88, scale: 0.985 } as const; +const PLAY_ENTRANCE_ANIMATE = { opacity: 1, scale: 1 } as const; +const PLAY_ENTRANCE_TRANSITION = { duration: 0.22, ease: 'easeOut' } as const; + +function shouldPlayEntranceAnimation() { + if (typeof window === 'undefined') return false; + + try { + return window.sessionStorage.getItem(PLAY_ENTRANCE_SESSION_KEY) !== '1'; + } catch { + return false; + } +} + /** * Renders the win-rate stat line ("13% win rate · 104 ranked games") with white @@ -24,10 +39,10 @@ import { getNextTierBand } from '@/utils/rankedTier'; * split on " · " into its two halves; in both EN and KA each half starts with * its number, so we wrap the leading numeric token of each half in yellow. */ -function WinRateStat({ text, className }: { text: string; className?: string }) { +function WinRateStat({ text, className, style }: { text: string; className?: string; style?: React.CSSProperties }) { const halves = text.split(' · '); return ( - + {halves.map((half, i) => { const match = half.match(/^(\d[\d.,]*%?)(.*)$/); return ( @@ -85,6 +100,7 @@ export function ModeSelectionScreen({ }: ModeSelectionScreenProps) { const { t, locale } = useLocale(); const [selectedMode, setSelectedMode] = useState<'ranked' | 'friendly' | 'solo' | null>(null); + const [playEntranceAnimation] = useState(shouldPlayEntranceAnimation); const isPlacementInProgress = rankedProfile ? rankedProfile.placementStatus !== 'placed' : false; const placementPlayed = rankedProfile?.placementPlayed ?? 0; const placementRequired = Math.max(1, rankedProfile?.placementRequired ?? 3); @@ -114,6 +130,19 @@ export function ModeSelectionScreen({ letterSpacing: "0", lineHeight: 1, } as const; + // Shared Poppins style for body/label/button text (replaces the old + // font-black/font-bold Duolingo weights). Only Poppins 600 is loaded. + const poppins = { fontFamily: "'Poppins', sans-serif", fontWeight: 600 } as const; + + useEffect(() => { + if (!playEntranceAnimation) return; + + try { + window.sessionStorage.setItem(PLAY_ENTRANCE_SESSION_KEY, '1'); + } catch { + // Session storage can be unavailable in private or restricted contexts. + } + }, [playEntranceAnimation]); const handleConfirm = () => { if (!selectedMode) return; @@ -134,12 +163,15 @@ export function ModeSelectionScreen({ const hasPreviewObjectives = previewObjectives.length > 0; return ( -
+ {/* ─── 1. Ranked Hero Card ─── */} - { if (onRankedIntercept?.()) return; setSelectedMode('ranked'); @@ -181,7 +213,7 @@ export function ModeSelectionScreen({ > {t('play.rankedMatch')} -
+
{rankedProfileLoading ? t('play.rankedSubtitle') : isPlacementInProgress @@ -191,7 +223,7 @@ export function ModeSelectionScreen({
-
+
{t('common.play')}
@@ -200,7 +232,7 @@ export function ModeSelectionScreen({ {/* Right: RP stats */}
-
+
{displayRp}/{nextTierTargetRp ?? 600} RP
@@ -210,10 +242,11 @@ export function ModeSelectionScreen({ {!rankedProfileLoading && ( )} -
+
{isPlacementInProgress ? t( placementMatchesLeft === 1 @@ -239,7 +272,7 @@ export function ModeSelectionScreen({ > {t('play.rankedMatch')} -
+
{rankedProfileLoading ? t('play.rankedSubtitle') : isPlacementInProgress @@ -248,13 +281,13 @@ export function ModeSelectionScreen({
-
+
{displayRp}/{nextTierTargetRp ?? 600} RP
-
+
{isPlacementInProgress ? t( placementMatchesLeft === 1 @@ -282,23 +315,21 @@ export function ModeSelectionScreen({ {!rankedProfileLoading && ( )}
-
+
{t('common.play')}
- +
{/* ─── 2. Secondary Modes Grid ─── */} - {/* Friendly Match */} @@ -330,7 +361,7 @@ export function ModeSelectionScreen({ > {t('play.friendlyMatch')} -

{t('play.friendlySubtitle')}

+

{t('play.friendlySubtitle')}

{/* Mobile: icon (centered, right under subtitle) + PLAY (bottom, full width) */}
@@ -342,13 +373,13 @@ export function ModeSelectionScreen({ className="h-[110px] w-[110px] object-contain pointer-events-none" />
-
+
{t('common.play')}
{/* Desktop: bottom-left PLAY */}
-
+
{t('common.play')}
@@ -384,7 +415,7 @@ export function ModeSelectionScreen({ > {t('play.dailyChallenge')} -

{t('play.dailySubtitle')}

+

{t('play.dailySubtitle')}

{/* Mobile: icon (centered, right under subtitle) + PLAY (bottom, full width) */}
@@ -396,33 +427,30 @@ export function ModeSelectionScreen({ className="h-[150px] w-full object-contain pointer-events-none" />
-
+
{t('common.play')}
{/* Desktop: bottom-left PLAY */}
-
+
{t('common.play')}
- +
{/* ─── 3. Objectives ─── */} - +
-

+

{t('play.objectivesTitle')}

{t('common.viewAll')} @@ -455,7 +483,7 @@ export function ModeSelectionScreen({
-

{t('play.objectivesUnavailable')}

+

{t('play.objectivesUnavailable')}

{t('play.objectivesUnavailableHint')}

)} @@ -473,12 +501,12 @@ export function ModeSelectionScreen({
-

{getI18nText(objective.title, locale)}

+

{getI18nText(objective.title, locale)}

{getI18nText(objective.description, locale)}

-
+
{objective.progress}/{objective.target} {t('play.objectiveRewardCoins', { count: objective.rewardCoins })}
@@ -516,16 +544,16 @@ export function ModeSelectionScreen({
-

{t('play.objectivesUnavailable')}

-

{t('play.objectivesUnavailableHint')}

+

{t('play.objectivesUnavailable')}

+

{t('play.objectivesUnavailableHint')}

- 0/1 - {t('play.objectiveRewardCoinsAndXp')} + 0/1 + {t('play.objectiveRewardCoinsAndXp')}
)} @@ -546,22 +574,22 @@ export function ModeSelectionScreen({
-

{getI18nText(objective.title, locale)}

-

{getI18nText(objective.description, locale)}

+

{getI18nText(objective.title, locale)}

+

{getI18nText(objective.description, locale)}

- {objective.progress}/{objective.target} - {t('play.objectiveRewardCoins', { count: objective.rewardCoins })} + {objective.progress}/{objective.target} + {t('play.objectiveRewardCoins', { count: objective.rewardCoins })}
); })}
- +
{/* ─── 5. Recent Matches ─── */} @@ -578,6 +606,6 @@ export function ModeSelectionScreen({ isOpen={selectedMode === 'friendly'} onOpenChange={(open) => !open && setSelectedMode(null)} /> -
+ ); } diff --git a/src/features/play/RankedCategoryBlockingScreen.tsx b/src/features/play/RankedCategoryBlockingScreen.tsx index 2e9351d0..bf05922f 100644 --- a/src/features/play/RankedCategoryBlockingScreen.tsx +++ b/src/features/play/RankedCategoryBlockingScreen.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ShowdownScreen } from '@/components/ShowdownScreen'; -import { motion } from 'motion/react'; import { Volume2, VolumeX } from 'lucide-react'; import { isMuted as getIsMuted, toggleMute } from '@/lib/sounds/gameSounds'; import { useRealtimeMatchStore } from '@/stores/realtimeMatch.store'; @@ -198,32 +197,46 @@ export function BanCategoryView({ {/* ── Content ── */}
{/* Heading */} -

- {paused ? 'Waiting' : phase === 'ban' ? 'Ban Category' : 'Get Ready'} -

-

{paused - ? 'Opponent disconnected. Draft resumes when they return.' + ? t('possession.waiting') : phase === 'ban' - ? 'Tap a card to remove it. One category remains for Half 1.' - : 'Match starting with selected Half 1 category…'} -

-
+ ? t('banCategory.title') + : t('possession.getReady')} + + {/* Turn indicator — clearly shows whose turn it is to ban. */} + {phase === 'ban' && !paused ? ( +

+ {currentActor === 'player' + ? t('possession.halftime.yourTurn') + : t('possession.halftime.opponentBanning')} +

+ ) : ( +

+ {paused + ? t('banCategory.draftResumes') + : t('training.matchStartingHalf1')} +

+ )} +
{/* Category Cards — shared BanCategoryCard matches /play mode-selection style */} - +
{categories.map((category, i) => { const isPlayerBanned = category.id === playerBannedId; @@ -237,8 +250,10 @@ export function BanCategoryView({ currentActor !== 'player' || phase !== 'ban'; - const fadedOut = Boolean(playerBannedId && !isPlayerBanned && !isOpponentBanned); - + // Only the BANNED card dims (via isBanned: grayscale + BANNED stamp). + // The not-yet-banned cards stay fully visible while waiting for the + // opponent — matching prod, where the card's entrance motion pinned + // inline opacity:1 and silently overrode this opacity-30 fade. return ( ); })}
- +
); @@ -305,7 +319,7 @@ export function RankedCategoryBlockingScreen() { [opponentMember?.avatarUrl] ); const opponentId = opponentMember?.userId ?? 'opponent'; - const opponentUsername = opponentMember?.username ?? 'Opponent'; + const opponentUsername = opponentMember?.username ?? t('possession.opponent'); useEffect(() => { if (!draft || showShowdown || draftPaused) return; diff --git a/src/features/possession/RealtimePossessionMatchScreen.tsx b/src/features/possession/RealtimePossessionMatchScreen.tsx index ec786da9..70505d58 100644 --- a/src/features/possession/RealtimePossessionMatchScreen.tsx +++ b/src/features/possession/RealtimePossessionMatchScreen.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { Volume2, VolumeX } from 'lucide-react'; import { QuitMatchModal } from '@/components/match/QuitMatchModal'; @@ -12,6 +12,7 @@ import { HalftimeScreen } from './components/HalftimeScreen'; import { KickoffCountdownOverlay } from './components/KickoffCountdownOverlay'; import { PenaltyStartCountdownOverlay } from './components/PenaltyStartCountdownOverlay'; import { MatchHudIconButton } from './components/MatchHudPrimitives'; +import { PenaltyMatchEndOverlay } from './components/PenaltyMatchEndOverlay'; import { PossessionMatchViewport } from './components/PossessionMatchViewport'; import { PossessionQuestionArea } from './components/PossessionQuestionArea'; import { usePossessionBarBattleFlights } from './hooks/usePossessionBarBattleFlights'; @@ -50,10 +51,17 @@ export function RealtimePossessionMatchScreen(props: RealtimePossessionMatchScre const barBattleFlights = usePossessionBarBattleFlights(); const matchPaused = useRealtimeMatchStore((state) => state.matchPaused); const hasMatch = useRealtimeMatchStore((state) => state.match != null); + const finalResults = useRealtimeMatchStore((state) => state.match?.finalResults ?? null); + const selfUserId = useRealtimeMatchStore((state) => state.selfUserId); + const opponentInfo = useRealtimeMatchStore((state) => state.match?.opponent ?? null); const pauseUntil = useRealtimeMatchStore((state) => state.pauseUntil); const remainingReconnects = useRealtimeMatchStore((state) => state.remainingReconnects); const forfeitPending = useRealtimeMatchStore((state) => state.forfeitPending); const [pauseNowMs, setPauseNowMs] = useState(() => Date.now()); + const [completedPenaltySplash, setCompletedPenaltySplash] = useState<{ + resultKey: string; + qIndex: number; + } | null>(null); const { isReady, @@ -102,6 +110,42 @@ export function RealtimePossessionMatchScreen(props: RealtimePossessionMatchScre : forfeitPending?.reason === 'opponent_reconnect_limit' ? t('forfeit.opponentDidNotReconnect') : t('forfeit.matchForfeited'); + const penaltyMatchEndOverlay = useMemo(() => { + if (!finalResults || finalResults.winnerDecisionMethod !== 'penalty_goals' || !selfUserId) { + return null; + } + const myResult = finalResults.players[selfUserId]; + const opponentEntry = Object.entries(finalResults.players).find(([userId]) => userId !== selfUserId); + const opponentResult = opponentEntry?.[1] ?? null; + if (!myResult || !opponentResult) return null; + + return { + playerWon: finalResults.winnerId === selfUserId, + myPenaltyGoals: myResult.penaltyGoals ?? 0, + oppPenaltyGoals: opponentResult.penaltyGoals ?? 0, + playerRankPoints: finalResults.rankedOutcome?.byUserId[selfUserId]?.newRp ?? null, + opponentRankPoints: opponentInfo?.rp ?? null, + }; + }, [finalResults, opponentInfo?.rp, selfUserId]); + const penaltyFinalResultKey = finalResults + ? `${finalResults.matchId}:${finalResults.resultVersion}` + : null; + + const showPenaltyMatchEndOverlay = + Boolean(penaltyMatchEndOverlay) + && completedPenaltySplash?.resultKey === penaltyFinalResultKey; + + const handlePenaltySplashComplete = useCallback((localQuestionIndex: number | null) => { + if (!penaltyMatchEndOverlay || !penaltyFinalResultKey || localQuestionIndex === null) return; + setCompletedPenaltySplash((current) => ( + current?.resultKey === penaltyFinalResultKey + ? current + : { + resultKey: penaltyFinalResultKey, + qIndex: localQuestionIndex, + } + )); + }, [penaltyFinalResultKey, penaltyMatchEndOverlay]); if (!isReady) { const showPendingKickoff = showStartCountdown || hasMatch; @@ -246,7 +290,10 @@ export function RealtimePossessionMatchScreen(props: RealtimePossessionMatchScre
{viewportModel && ( - + {showQuestionArea && questionAreaModel && ( )} @@ -258,6 +305,27 @@ export function RealtimePossessionMatchScreen(props: RealtimePossessionMatchScre )}
+ + {penaltyMatchEndOverlay && showPenaltyMatchEndOverlay && ( + + )} + + -
+
{score} 100
@@ -49,26 +59,33 @@ export function GoalProgressBar({ ); } - // Vertical (web): fills from the bottom (0) up to the goal (100) at the top. + // Vertical (web): fills toward the goal (100) at the player's attacking end. + // First half the goal is at the top and the fill grows up; in the second half + // the pitch flips, so the goal moves to the bottom and the fill grows down. // The score rides on the tip of the fill as it grows. return (
- - 100 - + {!mirrored && ( + + 100 + + )}
- {/* white goal line at the very top of the track */} - + {/* white goal line at the goal end of the track */} + {/* score + tick ride on the LEFT of the fill's tip */} @@ -77,6 +94,11 @@ export function GoalProgressBar({
+ {mirrored && ( + + 100 + + )}
); } diff --git a/src/features/possession/components/HalftimeScreen.tsx b/src/features/possession/components/HalftimeScreen.tsx index f50609c4..fdc2f618 100644 --- a/src/features/possession/components/HalftimeScreen.tsx +++ b/src/features/possession/components/HalftimeScreen.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; +import { motion } from 'motion/react'; import { cn } from '@/lib/utils'; import { PitchVisualization } from './PitchVisualization'; import { BanCategoryCard } from '@/components/shared/BanCategoryCard'; @@ -21,6 +21,9 @@ interface HalftimeScreenProps { playerAvatarCustomization?: AvatarCustomization | null; opponentAvatarCustomization?: AvatarCustomization | null; playerPosition: number; + /** Ranked points — renders the RP pill under each name, like the draft header. */ + playerRankPoints?: number | null; + opponentRankPoints?: number | null; /** ISO-country code (e.g. "ge", "us") — renders a flag badge on the avatar. */ playerCountryCode?: string | null; opponentCountryCode?: string | null; @@ -33,6 +36,8 @@ interface HalftimeScreenProps { opponentBan?: string | null; onBanCategory?: (categoryId: string) => void; onBanPhaseShown?: () => void; + /** When true this is the pre-penalty category ban — shows a "Penalties" heading. */ + isPenaltyBan?: boolean; } const FALLBACK_HALFTIME_SECONDS = 20; @@ -87,6 +92,8 @@ export function HalftimeScreen({ playerAvatarCustomization = null, opponentAvatarCustomization = null, playerPosition, + playerRankPoints = null, + opponentRankPoints = null, playerCountryCode = null, opponentCountryCode = null, deadlineAt = null, @@ -98,6 +105,7 @@ export function HalftimeScreen({ opponentBan = null, onBanCategory, onBanPhaseShown, + isPenaltyBan = false, }: HalftimeScreenProps) { const { t } = useLocale(); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -171,173 +179,188 @@ export function HalftimeScreen({ : false; const canBan = myTurn; + if (!visible) { + return null; + } + return ( - - {visible && ( - - {/* Background: blurred pitch + overlays */} -
-
-
- -
-
-
-
+ {/* Background: blurred pitch + overlays */} +
+
+
+
+
+
+
+
- {/* Content */} -
- - {/* Half Time label — flat, no 3D border */} - - - {t('possession.halfTime')} - - + {/* Content — same width as the pre-match draft (max-w-3xl) so cards and + avatars render at the same size across all three ban screens. */} +
+ {/* Half Time label — flat, no 3D border */} +
+ + {isPenaltyBan ? t('possession.penaltiesBanTitle') : t('possession.halfTime')} + +
- {/* Compact score row — flat, no surrounding card - (avatars w/ flags | score | avatars w/ flags, timer below) */} - -
- {/* Player */} -
- - - {playerName} - + {/* Score row — draft-style headers (avatar in a colored circle + name), + score in the centre, timer below. The non-acting player's avatar is + dimmed during the ban phase so it's clear whose turn it is. */} +
+
+ {/* Player — avatar with name + RP to the SIDE, like the draft. */} +
+
+ +
+
+
+ {playerName}
+ + {playerRankPoints != null ? `${playerRankPoints} RP` : '— RP'} + +
+
- {/* Score */} -
- - {playerGoals} - - : - - {opponentGoals} - -
+ {/* Score */} +
+ + {playerGoals} + + : + + {opponentGoals} + +
- {/* Opponent */} -
- - - {opponentName} - + {/* Opponent — mirrored: avatar on the right, name + RP to its side. */} +
+
+ +
+
+
+ {opponentName}
+ + {opponentRankPoints != null ? `${opponentRankPoints} RP` : '— RP'} +
+
+
- {/* Timer appears only after the backend confirms the ban UI deadline. */} - {banTimerReady && ( - - )} - + {/* Timer appears only after the backend confirms the ban UI deadline. */} + {banTimerReady && ( + + )} +
- {/* Ban phase — slides in after intro delay */} - - {showBanPhase && ( - - {/* Section title */} - - Ban 1 Category Each - + {/* Ban phase — slides up into view after the intro. */} + {showBanPhase && ( + + {/* Section title */} +
+ {t('possession.halftime.banTitle')} +
- {/* Category cards — shared BanCategoryCard mirrors /play style */} -
- {categoryOptions.map((category, index) => { - const isMyBan = myBan === category.id; - const isOpponentBan = opponentBan === category.id; - const isBanned = isMyBan || isOpponentBan; - const isRemaining = bothBansSubmitted && !isMyBan && !isOpponentBan && remainingCategory?.id === category.id; - const disabled = isBanned || !canBan; + {/* Turn indicator — your turn vs opponent banning, like the draft. */} + {!bothBansSubmitted && ( +
+ {canBan + ? t('possession.halftime.yourTurn') + : t('possession.halftime.opponentBanning')} +
+ )} - return ( - - ); - })} -
+ {/* Category cards — shared BanCategoryCard mirrors /play style */} +
+ {categoryOptions.map((category, index) => { + const isMyBan = myBan === category.id; + const isOpponentBan = opponentBan === category.id; + const isBanned = isMyBan || isOpponentBan; + const isRemaining = bothBansSubmitted && !isMyBan && !isOpponentBan && remainingCategory?.id === category.id; + const disabled = isBanned || !canBan; - {/* Status text */} - - {bothBansSubmitted - ? t('possession.halftime.secondHalfCategory', { name: remainingCategory?.name ?? t('possession.halftime.deciding') }) - : myBan - ? t('possession.halftime.banWaitingOpponent') - : !canBan - ? t('possession.halftime.banOpponentChoosing') - : t('possession.halftime.banChooseCategory')} - - - )} - -
-
- )} -
+ return ( + + ); + })} +
+ + {/* Status text */} +
+ {bothBansSubmitted + ? t('possession.halftime.secondHalfCategory', { name: remainingCategory?.name ?? t('possession.halftime.deciding') }) + : myBan + ? t('possession.halftime.banWaitingOpponent') + : !canBan + ? t('possession.halftime.banOpponentChoosing') + : t('possession.halftime.banChooseCategory')} +
+ + )} +
+
); } diff --git a/src/features/possession/components/LiveSpecialQuestionPanel.tsx b/src/features/possession/components/LiveSpecialQuestionPanel.tsx index 329394f3..abf329b4 100644 --- a/src/features/possession/components/LiveSpecialQuestionPanel.tsx +++ b/src/features/possession/components/LiveSpecialQuestionPanel.tsx @@ -23,6 +23,7 @@ */ import type { ReactNode } from 'react'; import { useLocale } from '@/contexts/LocaleContext'; +import { MAX_PENALTY_ROUNDS } from '../types/possession.types'; import { LiveCluesPanel } from './live-special/LiveCluesPanel'; import { LiveCountdownPanel } from './live-special/LiveCountdownPanel'; import { LivePutInOrderPanel } from './live-special/LivePutInOrderPanel'; @@ -37,6 +38,10 @@ export function LiveSpecialQuestionPanel(props: LiveSpecialQuestionPanelProps) { matchId, qIndex, totalQuestions, + isPenaltyPhase = false, + penaltyDisplayRound, + penaltyDisplayTotal, + isPenaltySuddenDeath = false, question, showOptions, timeRemaining, @@ -51,6 +56,14 @@ export function LiveSpecialQuestionPanel(props: LiveSpecialQuestionPanelProps) { } = props; const displayQuestionNum = qIndex + 1; + const counterLabel = isPenaltyPhase + ? isPenaltySuddenDeath + ? t('possession.questionCounter', { current: 1, total: 1 }) + : t('possession.penaltyRound', { + round: penaltyDisplayRound ?? 1, + max: penaltyDisplayTotal ?? MAX_PENALTY_ROUNDS, + }) + : t('possession.questionCounter', { current: displayQuestionNum, total: totalQuestions }); const displayTimer = Math.max(0, timeRemaining ?? 0); const timerLabel = displayTimer >= 10 ? `${displayTimer}` : `0${displayTimer}`; @@ -119,7 +132,7 @@ export function LiveSpecialQuestionPanel(props: LiveSpecialQuestionPanelProps) { className="font-poppins flex flex-1 items-center justify-center rounded-[16px] bg-brand-blue px-5 text-white h-[40px] sm:h-[52px] md:h-[62px] lg:h-[72px]" style={{ fontWeight: 600, fontSize: 'clamp(14px, 2.2vw, 26px)' }} > - {t('possession.questionCounter', { current: displayQuestionNum, total: totalQuestions })} + {counterLabel}
; + penaltyOpponentAttempts?: Array<'goal' | 'miss'>; playerPoints?: number; opponentPoints?: number; penaltyRound: number; @@ -31,11 +34,12 @@ interface PenaltyHUDProps { export function PenaltyHUD({ penaltyPlayerScore, penaltyOpponentScore, + penaltyPlayerAttempts, + penaltyOpponentAttempts, playerPoints = 0, opponentPoints = 0, penaltyRound, isPenaltySuddenDeath, - isPlayerShooter, playerName, opponentName, playerAvatarCustomization = null, @@ -45,8 +49,55 @@ export function PenaltyHUD({ onQuit, }: PenaltyHUDProps) { const { t } = useLocale(); + + // Sudden death restarts the pip rows: capture each side's cumulative score at + // the moment SD begins, then show only the SD-relative goals so the dots clear + // to empty and refill round by round (instead of staying at all-5-filled). + // Adjust-state-during-render pattern (no effect): the baseline is set the first + // render SD is active and cleared when it ends. See react.dev — "storing + // information from previous renders". + const [sdBaseline, setSdBaseline] = useState<{ + playerScore: number; + opponentScore: number; + playerAttempts: number; + opponentAttempts: number; + } | null>(null); + if (isPenaltySuddenDeath && sdBaseline === null) { + setSdBaseline({ + playerScore: penaltyPlayerScore, + opponentScore: penaltyOpponentScore, + playerAttempts: penaltyPlayerAttempts?.length ?? penaltyPlayerScore, + opponentAttempts: penaltyOpponentAttempts?.length ?? penaltyOpponentScore, + }); + } else if (!isPenaltySuddenDeath && sdBaseline !== null) { + setSdBaseline(null); + } + + const baseline = isPenaltySuddenDeath ? sdBaseline : null; + const pipPlayerScore = baseline ? Math.max(0, penaltyPlayerScore - baseline.playerScore) : penaltyPlayerScore; + const pipOpponentScore = baseline ? Math.max(0, penaltyOpponentScore - baseline.opponentScore) : penaltyOpponentScore; + // Only MAX_PENALTY_ROUNDS pip slots render, so keep the MOST RECENT attempts + // — otherwise extra sudden-death rounds would silently drop the latest results. + const playerPips = ((penaltyPlayerAttempts && penaltyPlayerAttempts.length > 0) + ? (baseline ? penaltyPlayerAttempts.slice(baseline.playerAttempts) : penaltyPlayerAttempts) + : Array.from({ length: pipPlayerScore }, () => 'goal' as const) + ).slice(-MAX_PENALTY_ROUNDS); + const opponentPips = ((penaltyOpponentAttempts && penaltyOpponentAttempts.length > 0) + ? (baseline ? penaltyOpponentAttempts.slice(baseline.opponentAttempts) : penaltyOpponentAttempts) + : Array.from({ length: pipOpponentScore }, () => 'goal' as const) + ).slice(-MAX_PENALTY_ROUNDS); + const pipClassName = (result: 'goal' | 'miss' | undefined) => { + if (result === 'goal') return 'bg-brand-green-light border-brand-green-light'; + if (result === 'miss') return 'bg-brand-red-soft border-brand-red-soft'; + return 'bg-transparent border-white/20'; + }; + return ( -
+
{onQuit && ( {phase === 'penalty-playing' ? timeRemaining : '\u2014'} -
- {isPlayerShooter ? t('possession.youShoot') : t('possession.youSave')} -
@@ -115,13 +163,13 @@ export function PenaltyHUD({
{Array.from({ length: MAX_PENALTY_ROUNDS }).map((_, i) => ( -
+
))}
{t('possession.pens')}
{Array.from({ length: MAX_PENALTY_ROUNDS }).map((_, i) => ( -
+
))}
diff --git a/src/features/possession/components/PenaltyMatchEndOverlay.tsx b/src/features/possession/components/PenaltyMatchEndOverlay.tsx new file mode 100644 index 00000000..035f04b7 --- /dev/null +++ b/src/features/possession/components/PenaltyMatchEndOverlay.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { motion } from 'motion/react'; +import { AvatarDisplay } from '@/components/AvatarDisplay'; +import { useLocale } from '@/contexts/LocaleContext'; +import type { AvatarCustomization } from '@/types/game'; + +/** + * Shown after the FINAL, match-deciding penalty kick has FULLY played out (the + * shot/save animation finished), as a brief "this is how it ended" beat before + * the results screen. + * + * Reported bug it fixes: when the deciding penalty was scored the match cut + * straight to the end screen — the shot animation was truncated and there was no + * who-won / final-score moment, so players couldn't tell what happened. + * + * Visual language mirrors {@link HalftimeScreen}'s header (dimmed backdrop + + * avatar-in-colored-circle + name + RP, score in the centre) but WITHOUT the + * ban cards — just the penalty score and a WON / LOST title, held ~2s. + * + * Self-contained (no store access) so it can be driven from /dev/animations for + * iteration AND mounted in the real penalty flow once the look is dialed in. + */ +export interface PenaltyMatchEndOverlayProps { + visible: boolean; + playerWon: boolean; + /** Final penalty scoreboard from the local player's perspective. */ + myPenaltyGoals: number; + oppPenaltyGoals: number; + playerName: string; + opponentName: string; + playerAvatarUrl?: string; + opponentAvatarUrl?: string; + playerAvatarCustomization?: AvatarCustomization | null; + opponentAvatarCustomization?: AvatarCustomization | null; + playerCountryCode?: string | null; + opponentCountryCode?: string | null; + playerRankPoints?: number | null; + opponentRankPoints?: number | null; +} + +export function PenaltyMatchEndOverlay({ + visible, + playerWon, + myPenaltyGoals, + oppPenaltyGoals, + playerName, + opponentName, + playerAvatarUrl, + opponentAvatarUrl, + playerAvatarCustomization, + opponentAvatarCustomization, + playerCountryCode, + opponentCountryCode, + playerRankPoints, + opponentRankPoints, +}: PenaltyMatchEndOverlayProps) { + const { t } = useLocale(); + if (!visible) return null; + + const accent = playerWon ? '#38B60E' : '#FB3101'; // brand-green / brand-red + + return ( + + {/* Backdrop — fully opaque so the still-settling pitch / question card + underneath never bleeds through (that bleed-through was the "animation + didn't finish" look). Matches the halftime page surface. */} +
+
+ + {/* Content — same max width as the halftime/draft header. */} + + {/* WON / LOST title — sits where the "HALF TIME" label is on the ban screen. */} +
+ + {playerWon ? t('possession.won') : t('possession.didNotWin')} + +
+ + {/* Score row — identical structure to HalftimeScreen's header, but the + centre shows the PENALTY score and neither side is dimmed. */} +
+ {/* Player — avatar in a colored circle, name + RP to the side. */} +
+
+ +
+
+
+ {playerName} +
+ + {playerRankPoints != null ? `${playerRankPoints} RP` : '— RP'} + +
+
+ + {/* Penalty score */} +
+ + {myPenaltyGoals} + + : + + {oppPenaltyGoals} + +
+ + {/* Opponent — mirrored. */} +
+
+ +
+
+
+ {opponentName} +
+ + {opponentRankPoints != null ? `${opponentRankPoints} RP` : '— RP'} + +
+
+
+ + {/* Penalties label under the score */} +
+ {t('possession.penaltyShootout')} +
+
+ + ); +} diff --git a/src/features/possession/components/PenaltyStartCountdownOverlay.tsx b/src/features/possession/components/PenaltyStartCountdownOverlay.tsx index 9dcd364b..a1534bee 100644 --- a/src/features/possession/components/PenaltyStartCountdownOverlay.tsx +++ b/src/features/possession/components/PenaltyStartCountdownOverlay.tsx @@ -37,8 +37,9 @@ export function PenaltyStartCountdownOverlay({ display }: PenaltyStartCountdownO initial={{ y: -20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.15, duration: 0.4 }} - className="w-full text-balance text-center text-[clamp(1.5rem,7vw,2.25rem)] font-black uppercase leading-tight tracking-wide text-brand-red-soft font-fun [overflow-wrap:normal] [word-break:keep-all]" - style={{ textShadow: '0 0 30px rgba(255,75,75,0.5), 0 4px 0 rgba(200,40,40,0.8)' }} + className="w-full text-balance text-center text-[clamp(1.5rem,7vw,2.25rem)] font-black uppercase leading-tight tracking-wide text-brand-red-soft [overflow-wrap:normal] [word-break:keep-all]" + // family-only to preserve font-black / tracking + style={{ fontFamily: "'Poppins', sans-serif", textShadow: '0 0 30px rgba(255,75,75,0.5), 0 4px 0 rgba(200,40,40,0.8)' }} > {t('possession.penaltyShootout')} @@ -49,12 +50,20 @@ export function PenaltyStartCountdownOverlay({ display }: PenaltyStartCountdownO transition={{ type: 'spring', stiffness: 340, damping: 22 }} >
- + {display}
-
+
{t('possession.getReady')}
diff --git a/src/features/possession/components/PitchVisualization.tsx b/src/features/possession/components/PitchVisualization.tsx index 5918d2ae..b8b2ea9f 100644 --- a/src/features/possession/components/PitchVisualization.tsx +++ b/src/features/possession/components/PitchVisualization.tsx @@ -6,6 +6,7 @@ import { PitchMarker } from './pitch/PitchMarker'; import { PitchSvgDefs } from './pitch/PitchSvgDefs'; import { PitchBackground } from './pitch/PitchBackground'; import { PossessionTrackScene } from './pitch/PossessionTrackScene'; +import { BarBattleOverlay } from './BarBattleOverlay'; import { PitchBall } from './pitch/PitchBall'; import { PitchGoalNetRipple } from './pitch/PitchGoalNetRipple'; import { PitchHtmlActors } from './pitch/PitchHtmlActors'; @@ -81,6 +82,8 @@ export function PitchVisualization(props: PitchVisualizationProps) { useMarkerActors, playerHtmlIsShooter, opponentHtmlIsShooter, + playerFlipX, + opponentFlipX, playerHtmlActorMotion, opponentHtmlActorMotion, playerHtmlActorTransition, @@ -205,6 +208,23 @@ export function PitchVisualization(props: PitchVisualizationProps) { /> )} + {/* Penalty: reuse the ranked bar-battle overlay WITHOUT the open-play + possession-track background (which is meaningless over a penalty + pitch). Anchor bars to the penalty actor X (keeperX / penSpotX) so + they sit behind the penalty avatars and align with the +N flight + targets. penY === CY_ANCHORED, so the height is already correct. */} + {isPenalty && barBattle && ( + + )} + {/* === UNIFIED BALL — single persistent that never unmounts === */} {!hideBall && !renderHtmlPitchActors && !renderSimpleShotBall && (
- +
@@ -115,15 +118,18 @@ export function PossessionHUD({
{opponentName}
-
- - {opponentGoals} - +
+ +
+ + {opponentGoals} + +
@@ -209,7 +214,7 @@ function SpeedStreakBadge({ active }: { active: boolean }) { className="absolute inset-0 z-[120] flex items-center justify-center" >
diff --git a/src/features/possession/components/PossessionMatchViewport.tsx b/src/features/possession/components/PossessionMatchViewport.tsx index 3ad49016..24e0dfc4 100644 --- a/src/features/possession/components/PossessionMatchViewport.tsx +++ b/src/features/possession/components/PossessionMatchViewport.tsx @@ -2,6 +2,7 @@ import { type ComponentProps, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; +import { cn } from '@/lib/utils'; import { useLocale } from '@/contexts/LocaleContext'; import { GoalCelebrationOverlay } from './GoalCelebrationOverlay'; import { GoalProgressBar } from './GoalProgressBar'; @@ -47,6 +48,7 @@ export interface PossessionViewportModel { interface PossessionMatchViewportProps { model: PossessionViewportModel; children?: ReactNode; + onPenaltySplashComplete?: (localQuestionIndex: number | null) => void; } function usePitchBallMetrics(orientation: 'portrait' | 'landscape', pitchProps: PitchProps) { @@ -92,7 +94,13 @@ function usePitchBallMetrics(orientation: 'portrait' | 'landscape', pitchProps: return { containerRef, ballSizePx, ballCenterPx }; } -function PenaltySplash({ model }: { model: PenaltySplashModel | null }) { +function PenaltySplash({ + model, + onComplete, +}: { + model: PenaltySplashModel | null; + onComplete?: (localQuestionIndex: number | null) => void; +}) { const { t } = useLocale(); if (!model?.visible) return null; @@ -112,6 +120,9 @@ function PenaltySplash({ model }: { model: PenaltySplashModel | null }) { times: [0, 0.12, 0.25, 0.82, 1], ease: [0.22, 1, 0.36, 1], }} + onAnimationComplete={() => { + onComplete?.(localQuestionIndex); + }} className="absolute inset-x-0 top-[35%] z-30 flex pointer-events-none flex-col items-center" > {result === 'goal' ? ( @@ -122,15 +133,21 @@ function PenaltySplash({ model }: { model: PenaltySplashModel | null }) { /> ) : (
{t('possession.savedExclaim')}
)} -
+
{result === 'goal' ? (resultShooterIsMe ? t('possession.youScored') : t('possession.opponentScored')) : (resultShooterIsMe ? t('possession.keeperSavesIt') : t('possession.youSavedIt'))} @@ -139,7 +156,8 @@ function PenaltySplash({ model }: { model: PenaltySplashModel | null }) { ); } -export function PossessionMatchViewport({ model, children }: PossessionMatchViewportProps) { +export function PossessionMatchViewport({ model, children, onPenaltySplashComplete }: PossessionMatchViewportProps) { + const { t } = useLocale(); const { showMainUI, hud, pitchProps, goalCelebration, penaltySplash, muted, autoScrollKey } = model; const celebrationOwnsBall = Boolean(goalCelebration); const { @@ -168,7 +186,9 @@ export function PossessionMatchViewport({ model, children }: PossessionMatchView {showMainUI && (
- + {hud.kind !== 'penalty' && ( + + )}
@@ -185,7 +205,7 @@ export function PossessionMatchViewport({ model, children }: PossessionMatchView
- +
)} @@ -215,17 +235,44 @@ export function PossessionMatchViewport({ model, children }: PossessionMatchView )} - +
-
- -
+ {hud.kind !== 'penalty' && ( +
+ +
+ )} + + {/* Penalty: in place of the (hidden) possession meter, show who is + shooting vs in goal this round, so it's obvious at a glance. + Shown on mobile and web. */} + {hud.kind === 'penalty' && ( +
+
+ {hud.props.isPlayerShooter ? t('possession.youShoot') : t('possession.youSave')} +
+
+ )} )} - {children} + {/* During the goal celebration, blur + dim and disable the question + area below so users don't mistake the just-answered question for the + next one and start reading/answering early. Reverts when the + celebration ends and the next round loads. */} +
+ {children} +
); diff --git a/src/features/possession/components/__tests__/BarBattleOverlay.test.tsx b/src/features/possession/components/__tests__/BarBattleOverlay.test.tsx index 5fab8d64..b3c14e52 100644 --- a/src/features/possession/components/__tests__/BarBattleOverlay.test.tsx +++ b/src/features/possession/components/__tests__/BarBattleOverlay.test.tsx @@ -1,6 +1,7 @@ -import { render, screen } from '@testing-library/react'; +import { render, renderHook, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { BarBattleOverlay, type BarBattleState } from '../BarBattleOverlay'; +import { useBarBattleViewModel } from '../bar-battle/useBarBattleViewModel'; function rankedBattle(overrides: Partial = {}): BarBattleState { return { @@ -87,6 +88,104 @@ describe('BarBattleOverlay — anchored layout (variant="ranked_sim")', () => { }); }); +describe('BarBattleOverlay — penalty anchored layout', () => { + it('keeps a compact edge-keeper stack behind the avatar instead of under it', () => { + const { result } = renderHook(() => useBarBattleViewModel({ + battle: rankedBattle({ + phase: 'bars', + playerBars: 5, + opponentBars: 0, + playerPoints: 50, + opponentPoints: 0, + remainingDelta: 5, + }), + mirrored: false, + playerAvatarX: 33, + opponentAvatarX: 140, + isPortrait: false, + matchVariant: 'ranked_sim', + isPenalty: true, + })); + + const stackHalfWidth = (result.current.barW * 2.2) / 2; + expect(result.current.playerLayout.compact).toBe(true); + expect(result.current.playerLayout.compactX - stackHalfWidth).toBeGreaterThanOrEqual(4); + expect(result.current.playerLayout.compactX + stackHalfWidth).toBeLessThanOrEqual(29); + }); + + it('keeps a right-side penalty shooter row inside the visible pitch', () => { + const { result } = renderHook(() => useBarBattleViewModel({ + battle: rankedBattle({ + phase: 'bars', + playerBars: 0, + opponentBars: 10, + playerPoints: 0, + opponentPoints: 100, + remainingDelta: -10, + }), + mirrored: false, + playerAvatarX: 140, + opponentAvatarX: 360, + isPortrait: false, + matchVariant: 'ranked_sim', + isPenalty: true, + })); + + const xs = Array.from({ length: 10 }, (_, index) => result.current.opponentBarStartX(index)); + const rightEdges = xs.map((x) => x + result.current.barW); + expect(result.current.opponentLayout.compact).toBe(false); + expect(xs[0]).toBeLessThan(401); + expect(Math.min(...xs)).toBeGreaterThanOrEqual(4); + expect(Math.max(...rightEdges)).toBeLessThanOrEqual(496); + }); + + it('moves the left-goal keeper save shield in front of the avatar', () => { + const { result } = renderHook(() => useBarBattleViewModel({ + battle: rankedBattle({ + phase: 'charge', + playerBars: 5, + opponentBars: 0, + playerPoints: 50, + opponentPoints: 0, + remainingDelta: 5, + penaltyOutcome: 'saved', + }), + mirrored: false, + playerAvatarX: 33, + opponentAvatarX: 140, + isPortrait: false, + matchVariant: 'ranked_sim', + isPenalty: true, + })); + + expect(result.current.playerBarDir).toBe(-1); + expect(result.current.playerKeeperShieldCenterX).toBeGreaterThan(33); + }); + + it('moves the right-goal keeper save shield in front of the avatar', () => { + const { result } = renderHook(() => useBarBattleViewModel({ + battle: rankedBattle({ + phase: 'charge', + playerBars: 0, + opponentBars: 5, + playerPoints: 0, + opponentPoints: 50, + remainingDelta: -5, + penaltyOutcome: 'saved', + }), + mirrored: false, + playerAvatarX: 360, + opponentAvatarX: 467, + isPortrait: false, + matchVariant: 'ranked_sim', + isPenalty: true, + })); + + expect(result.current.opponentBarDir).toBe(1); + expect(result.current.opponentKeeperShieldCenterX).toBeLessThan(467); + }); +}); + describe('BarBattleOverlay — variant prop resolution', () => { it('runs the anchored variant when variant prop is "ranked_sim"', () => { render( diff --git a/src/features/possession/components/__tests__/HalftimeScreen.test.tsx b/src/features/possession/components/__tests__/HalftimeScreen.test.tsx new file mode 100644 index 00000000..9a7556e7 --- /dev/null +++ b/src/features/possession/components/__tests__/HalftimeScreen.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// Stub heavy children so the test stays cheap and focused on the title. +vi.mock('../PitchVisualization', () => ({ PitchVisualization: () =>
})); +vi.mock('@/components/AvatarDisplay', () => ({ AvatarDisplay: () =>
})); +vi.mock('@/components/shared/BanCategoryCard', () => ({ BanCategoryCard: () =>
})); + +import { HalftimeScreen } from '../HalftimeScreen'; + +const baseProps = { + visible: true, + playerGoals: 0, + opponentGoals: 0, + playerName: 'Me', + opponentName: 'AI', + playerAvatarUrl: 'a1', + opponentAvatarUrl: 'a2', + playerPosition: 50, +}; + +describe('HalftimeScreen title', () => { + it('shows the Half Time heading for a normal second-half ban', () => { + render(); + expect(screen.getByText('Half Time')).toBeInTheDocument(); + expect(screen.queryByText('Penalties')).not.toBeInTheDocument(); + }); + + it('shows the Penalties heading when isPenaltyBan is set', () => { + render(); + expect(screen.getByText('Penalties')).toBeInTheDocument(); + expect(screen.queryByText('Half Time')).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/possession/components/__tests__/PenaltyHUD.test.tsx b/src/features/possession/components/__tests__/PenaltyHUD.test.tsx new file mode 100644 index 00000000..1e8babd6 --- /dev/null +++ b/src/features/possession/components/__tests__/PenaltyHUD.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { PenaltyHUD } from '../PenaltyHUD'; + +vi.mock('@/components/AvatarDisplay', () => ({ + AvatarDisplay: () =>
, +})); + +const baseProps = { + penaltyPlayerScore: 0, + penaltyOpponentScore: 0, + penaltyRound: 1, + isPenaltySuddenDeath: false, + isPlayerShooter: true, + playerName: 'Player', + opponentName: 'Opponent', + playerAvatarUrl: '', + opponentAvatarUrl: '', + timeRemaining: 10, + phase: 'penalty-question' as const, +}; + +describe('PenaltyHUD pips', () => { + it('colors made penalties green for both player and opponent, and misses red', () => { + render( + , + ); + + const playerPips = screen.getAllByTestId('penalty-player-pip'); + const opponentPips = screen.getAllByTestId('penalty-opponent-pip'); + + expect(playerPips[0]).toHaveClass('bg-brand-red-soft'); + expect(playerPips[1]).toHaveClass('bg-brand-green-light'); + expect(opponentPips[0]).toHaveClass('bg-brand-green-light'); + expect(opponentPips[0]).not.toHaveClass('bg-brand-red-soft'); + }); +}); diff --git a/src/features/possession/components/__tests__/PitchVisualization.test.tsx b/src/features/possession/components/__tests__/PitchVisualization.test.tsx index fa24d58c..2e93ee41 100644 --- a/src/features/possession/components/__tests__/PitchVisualization.test.tsx +++ b/src/features/possession/components/__tests__/PitchVisualization.test.tsx @@ -105,6 +105,28 @@ describe('PitchVisualization — penalty branches', () => { expect(avatars).toContain('opponent'); }); + it('mounts the bar-battle slot in penalties but still omits the possession track', () => { + const { container } = renderPitch({ + ...penaltyBase, + barBattle: { + key: 1, + phase: 'both-score', + playerBars: 10, + opponentBars: 0, + playerPoints: 100, + opponentPoints: 0, + remainingDelta: 10, + dividerX: 250, + }, + barBattleVariant: 'ranked_sim', + }); + // Bar battle renders over the penalty pitch... + expect(container.querySelector('[data-testid="bar-battle"]')).not.toBeNull(); + // ...but the open-play possession-track background must NOT (it's meaningless + // over a penalty pitch — we render BarBattleOverlay directly, not the scene). + expect(container.querySelector('rect[x="15"][y="70"][width="470"][height="90"]')).toBeNull(); + }); + it('renders the penalty goal net ripple when result is goal', () => { const { container } = renderPitch({ penaltyMode: { isPlayerShooter: true, result: 'goal', phase: 'result' }, diff --git a/src/features/possession/components/__tests__/barBattle.helpers.test.ts b/src/features/possession/components/__tests__/barBattle.helpers.test.ts new file mode 100644 index 00000000..1eebcdf9 --- /dev/null +++ b/src/features/possession/components/__tests__/barBattle.helpers.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { getChargeImpactXKeyframes } from '../bar-battle/barBattle.helpers'; + +describe('barBattle helpers', () => { + it('lets normal charge impacts recoil toward the original lane', () => { + expect(getChargeImpactXKeyframes(20, 40, false)).toEqual([20, 20, 60, 28]); + }); + + it('keeps saved penalty shield impacts at the contact point', () => { + expect(getChargeImpactXKeyframes(20, 40, true)).toEqual([20, 20, 60, 60]); + }); +}); diff --git a/src/features/possession/components/bar-battle/BarBattleBar.tsx b/src/features/possession/components/bar-battle/BarBattleBar.tsx index a5205174..12ed89e2 100644 --- a/src/features/possession/components/bar-battle/BarBattleBar.tsx +++ b/src/features/possession/components/bar-battle/BarBattleBar.tsx @@ -16,7 +16,7 @@ import { motion } from 'motion/react'; import type { BarBattlePhase } from './barBattle.types'; -import { BAR_H, BAR_RX, BAR_W, CY } from './barBattle.helpers'; +import { BAR_H, BAR_RX, BAR_W, CY, getChargeImpactXKeyframes } from './barBattle.helpers'; interface BarBattleBarProps { spawnX: number; @@ -32,6 +32,8 @@ interface BarBattleBarProps { survived: boolean; chargeOrder: number; chargeHitOffsetX?: number; + holdChargeImpact?: boolean; + resultInitialX?: number; /** Vertical centre for the bar; classic uses CY=115, anchored uses CY_ANCHORED. */ cy?: number; barW?: number; @@ -58,6 +60,8 @@ export function BarBattleBar({ survived, chargeOrder, chargeHitOffsetX = 0, + holdChargeImpact = false, + resultInitialX, cy = CY, barW = BAR_W, barH = BAR_H, @@ -129,7 +133,7 @@ export function BarBattleBar({ key={`bar-result-${index}`} x={0} y={y} width={W} height={H} rx={RX} fill={`url(#${gradientId})`} - initial={{ opacity: 0.95, x: marchX }} + initial={{ opacity: 0.95, x: resultInitialX ?? marchX }} animate={{ x: pushX, opacity: 1 }} transition={{ type: 'spring', stiffness: 80, damping: 12, mass: 0.7 }} /> @@ -153,7 +157,7 @@ export function BarBattleBar({ initial={{ x: initialX, y: initialYOffset }} animate={{ x: isChargeImpact - ? [targetX, targetX, targetX + chargeHitOffsetX, targetX + chargeHitOffsetX * 0.2] + ? getChargeImpactXKeyframes(targetX, chargeHitOffsetX, holdChargeImpact) : targetX, y: 0, }} diff --git a/src/features/possession/components/bar-battle/BarBattleBars.tsx b/src/features/possession/components/bar-battle/BarBattleBars.tsx index b5ac723a..8447415f 100644 --- a/src/features/possession/components/bar-battle/BarBattleBars.tsx +++ b/src/features/possession/components/bar-battle/BarBattleBars.tsx @@ -62,7 +62,23 @@ export function BarBattleBars({ blueGrad, redGrad, battleClip, + isPenalty, + playerKeeperShieldCenterX, + opponentKeeperShieldCenterX, } = vm; + const isPenaltySave = isPenalty && battle.penaltyOutcome === 'saved'; + const playerChargeTargetX = isPenaltySave && remainingDelta > 0 && playerKeeperShieldCenterX != null + ? playerKeeperShieldCenterX + : playerAvatarX != null + ? playerAvatarX + playerBarDir * 20 + : null; + const opponentChargeTargetX = isPenaltySave && remainingDelta < 0 && opponentKeeperShieldCenterX != null + ? opponentKeeperShieldCenterX + : opponentAvatarX != null + ? opponentAvatarX + opponentBarDir * 20 + : null; + const playerShieldSaveActive = isPenaltySave && remainingDelta > 0 && playerChargeTargetX != null; + const opponentShieldSaveActive = isPenaltySave && remainingDelta < 0 && opponentChargeTargetX != null; return ( @@ -71,7 +87,7 @@ export function BarBattleBars({ key={`p-stack-${battle.key}`} spawnX={playerLayout.compactX} marchX={playerLayout.compactX} - pushX={playerLayout.compactX} + pushX={playerShieldSaveActive && playerChargeTargetX != null ? playerChargeTargetX : playerLayout.compactX} gradientId={blueGrad} count={playerStackCount} cancelCount={minBars} @@ -81,11 +97,12 @@ export function BarBattleBars({ chargeOrder={0} chargeHitOffsetX={!chargeLunges ? 0 - : remainingDelta > 0 && isAnchored && playerAvatarX != null - ? (playerAvatarX + playerBarDir * 20) - playerLayout.compactX + : remainingDelta > 0 && isAnchored && playerChargeTargetX != null + ? playerChargeTargetX - playerLayout.compactX : remainingDelta > 0 ? -playerBarDir * 34 : 0} + holdChargeImpact={playerShieldSaveActive} cy={cy} barW={barW} barH={barH} @@ -105,17 +122,20 @@ export function BarBattleBars({ const chargeHitOffsetX = !chargeLunges ? 0 : isSurvived && survivedIndex === 0 - ? isAnchored && playerAvatarX != null - ? (playerAvatarX + playerBarDir * 20) - playerBarStartX(i) + ? isAnchored && playerChargeTargetX != null + ? (playerChargeTargetX - barW / 2) - playerBarStartX(i) : -playerBarDir * 34 : 0; + const resultPushX = playerShieldSaveActive && survivedIndex === 0 && playerChargeTargetX != null + ? playerChargeTargetX - barW / 2 + : playerPushX(survivedIndex); return ( 0 ? Math.min(Math.max(Math.round(points / 10), 1), 12) : 0; + return points > 0 ? Math.min(Math.max(Math.round(points / 10), 1), 20) : 0; } /** Clamp an X coordinate so the centred shape of `width` stays inside the field. */ export function clampCenterX(x: number, width: number): number { return Math.max(FIELD_MIN_X + width / 2, Math.min(FIELD_MAX_X - width / 2, x)); } + +export function getChargeImpactXKeyframes( + targetX: number, + chargeHitOffsetX: number, + holdChargeImpact = false +): [number, number, number, number] { + const endOffsetX = holdChargeImpact ? chargeHitOffsetX : chargeHitOffsetX * 0.2; + return [targetX, targetX, targetX + chargeHitOffsetX, targetX + endOffsetX]; +} diff --git a/src/features/possession/components/bar-battle/barBattle.types.ts b/src/features/possession/components/bar-battle/barBattle.types.ts index 71d1d819..be5e4182 100644 --- a/src/features/possession/components/bar-battle/barBattle.types.ts +++ b/src/features/possession/components/bar-battle/barBattle.types.ts @@ -27,6 +27,7 @@ export interface BarBattleState { dividerX: number; /** `pulse` uses the charge glow without lunging the final bar into the avatar. */ chargeMode?: 'lunge' | 'pulse'; + penaltyOutcome?: 'goal' | 'saved' | null; } export type BarBattleVariant = 'ranked_sim' | 'friendly_possession'; diff --git a/src/features/possession/components/bar-battle/useBarBattleViewModel.ts b/src/features/possession/components/bar-battle/useBarBattleViewModel.ts index 4ff71852..bbc84d00 100644 --- a/src/features/possession/components/bar-battle/useBarBattleViewModel.ts +++ b/src/features/possession/components/bar-battle/useBarBattleViewModel.ts @@ -28,6 +28,7 @@ import { CY_ANCHORED, FIELD_MAX_X, FIELD_MIN_X, + PENALTY_KEEPER_SHIELD_OFFSET, clampCenterX, pointsToBarCount, } from './barBattle.helpers'; @@ -39,6 +40,8 @@ interface UseBarBattleViewModelArgs { opponentAvatarX?: number; isPortrait: boolean; matchVariant: BarBattleVariant | undefined; + /** Penalties zoom the pitch, so the anchored bars render smaller to fit. */ + isPenalty?: boolean; } export interface BarBattleViewModel { @@ -50,6 +53,7 @@ export interface BarBattleViewModel { battleClip: string; // Variant-resolved sizing isAnchored: boolean; + isPenalty: boolean; barW: number; barH: number; barGap: number; @@ -68,6 +72,8 @@ export interface BarBattleViewModel { // Directional layout playerBarDir: number; opponentBarDir: number; + playerKeeperShieldCenterX: number | null; + opponentKeeperShieldCenterX: number | null; // Per-side layouts playerLayout: AnchoredLayout; opponentLayout: AnchoredLayout; @@ -107,13 +113,16 @@ export function useBarBattleViewModel({ mirrored, playerAvatarX, opponentAvatarX, - isPortrait, matchVariant, + isPenalty = false, }: UseBarBattleViewModelArgs): BarBattleViewModel { const uid = useId(); const isAnchored = matchVariant === 'ranked_sim' && playerAvatarX != null && opponentAvatarX != null; + // The penalty pitch is zoomed in, so shrink the anchored bars to keep them + // proportionate to the larger avatars. + const penaltyScale = isAnchored && isPenalty ? 0.62 : 1; const { phase, playerBars, opponentBars, playerPoints, opponentPoints, remainingDelta, dividerX } = battle; const isDone = phase === 'done'; @@ -124,11 +133,19 @@ export function useBarBattleViewModel({ // Pick the active bar dimensions based on variant. Anchored bars are smaller // so they fit cleanly below the avatar circle without bleeding into the field. - const barW = isAnchored ? BAR_W_ANCHORED : BAR_W; - const barH = isAnchored ? BAR_H_ANCHORED : BAR_H; - const barGap = isAnchored ? BAR_GAP_ANCHORED : BAR_GAP; + // In penalties they shrink further (penaltyScale) for the zoomed pitch. + const barW = (isAnchored ? BAR_W_ANCHORED : BAR_W) * penaltyScale; + const barH = (isAnchored ? BAR_H_ANCHORED : BAR_H) * penaltyScale; + const barGap = (isAnchored ? BAR_GAP_ANCHORED : BAR_GAP) * penaltyScale; const barRx = isAnchored ? BAR_RX_ANCHORED : BAR_RX; const cy = isAnchored ? CY_ANCHORED : CY; + const avatarBarOffset = isAnchored && isPenalty ? AVATAR_BAR_OFFSET * penaltyScale : AVATAR_BAR_OFFSET; + const fieldMinX = isAnchored && isPenalty ? 4 : FIELD_MIN_X; + const fieldMaxX = isAnchored && isPenalty ? 496 : FIELD_MAX_X; + const clampBarCenterX = (x: number, width: number) => + isAnchored && isPenalty + ? Math.max(fieldMinX + width / 2, Math.min(fieldMaxX - width / 2, x)) + : clampCenterX(x, width); const minBars = Math.min(playerBars, opponentBars); const playerDir = mirrored ? 1 : -1; @@ -136,22 +153,27 @@ export function useBarBattleViewModel({ const playerLayoutBars = playerBars > 0 ? playerBars : pointsToBarCount(playerPoints); const opponentLayoutBars = opponentBars > 0 ? opponentBars : pointsToBarCount(opponentPoints); - // In portrait, SVG X maps to screen Y. The bar lane must extend away from - // the opposing avatar, which flips when the second half mirrors the pitch. - const portraitPlayerDir = playerAvatarX != null && opponentAvatarX != null && playerAvatarX > opponentAvatarX ? 1 : -1; - const portraitOpponentDir = opponentAvatarX != null && playerAvatarX != null && opponentAvatarX > playerAvatarX ? 1 : -1; - const playerPreferredBarDir = isAnchored && isPortrait ? portraitPlayerDir : playerDir; - const opponentPreferredBarDir = isAnchored && isPortrait ? portraitOpponentDir : opponentDir; + // When anchored, derive each side's bar direction from the ACTUAL avatar + // positions so bars always grow away from the opposing avatar — never across + // it. This is required for penalties, where avatars are placed by role + // (keeper/shooter), not by the half-based `mirrored` flag, so `playerDir`/ + // `opponentDir` (which key off `mirrored`) would point the wrong way and lay + // bars in front of the opposite avatar. In portrait SVG-X maps to screen-Y, + // but the away-from-opponent rule is the same. + const positionPlayerDir = playerAvatarX != null && opponentAvatarX != null && playerAvatarX > opponentAvatarX ? 1 : -1; + const positionOpponentDir = opponentAvatarX != null && playerAvatarX != null && opponentAvatarX > playerAvatarX ? 1 : -1; + const playerPreferredBarDir = isAnchored ? positionPlayerDir : playerDir; + const opponentPreferredBarDir = isAnchored ? positionOpponentDir : opponentDir; const compactW = barW * 2.2; const normalRowX = (avatarX: number, dir: number, count: number): number[] | null => { if (count <= 0) return []; - const firstX = avatarX + dir * AVATAR_BAR_OFFSET; + const firstX = avatarX + dir * avatarBarOffset; const maxStep = count <= 1 ? barW + barGap : dir > 0 - ? ((FIELD_MAX_X - barW) - firstX) / (count - 1) - : (firstX - FIELD_MIN_X) / (count - 1); + ? ((fieldMaxX - barW) - firstX) / (count - 1) + : (firstX - fieldMinX) / (count - 1); const step = Math.min(barW + barGap, maxStep); if (step < barW) return null; return Array.from({ length: count }, (_, i) => firstX + dir * i * step); @@ -163,12 +185,12 @@ export function useBarBattleViewModel({ ): AnchoredLayout => { const fallbackBarX = avatarX == null ? dividerX - : clampCenterX(avatarX + dir * AVATAR_BAR_OFFSET + barW / 2, barW); + : clampBarCenterX(avatarX + dir * avatarBarOffset + barW / 2, barW); if (!isAnchored || avatarX == null || count <= 0) { return { compact: false, dir, - rowX: (_i: number) => fallbackBarX - barW / 2, + rowX: () => fallbackBarX - barW / 2, compactX: fallbackBarX, splashX: fallbackBarX, landingX: fallbackBarX, @@ -176,12 +198,14 @@ export function useBarBattleViewModel({ } const xs = normalRowX(avatarX, dir, count); - const compactX = clampCenterX(avatarX + dir * (AVATAR_BAR_OFFSET + compactW / 2), compactW); + const compactGap = Math.max(4, barGap); + const compactOffset = isPenalty ? compactGap + compactW / 2 : avatarBarOffset + compactW / 2; + const compactX = clampBarCenterX(avatarX + dir * compactOffset, compactW); if (xs === null) { return { compact: true, dir, - rowX: (_i: number) => compactX, + rowX: () => compactX, compactX, splashX: compactX, landingX: compactX, @@ -204,6 +228,12 @@ export function useBarBattleViewModel({ const opponentLayout = buildAnchoredLayout(opponentAvatarX, opponentPreferredBarDir, opponentLayoutBars); const playerBarDir = playerLayout.dir; const opponentBarDir = opponentLayout.dir; + const playerKeeperShieldCenterX = isAnchored && isPenalty && playerAvatarX != null + ? clampBarCenterX(playerAvatarX - playerBarDir * PENALTY_KEEPER_SHIELD_OFFSET, compactW) + : null; + const opponentKeeperShieldCenterX = isAnchored && isPenalty && opponentAvatarX != null + ? clampBarCenterX(opponentAvatarX - opponentBarDir * PENALTY_KEEPER_SHIELD_OFFSET, compactW) + : null; const playerStackCount = (phase === 'result' || phase === 'charge') && remainingDelta > 0 ? remainingDelta : playerBars; const opponentStackCount = (phase === 'result' || phase === 'charge') && remainingDelta < 0 ? -remainingDelta : opponentBars; @@ -264,12 +294,12 @@ export function useBarBattleViewModel({ const playerSplashLandX = isAnchored && playerBars > 0 ? playerLayout.splashX : playerAvatarX != null - ? playerAvatarX + playerBarDir * AVATAR_BAR_OFFSET + ? playerAvatarX + playerBarDir * avatarBarOffset : dividerX + playerDir * (28 + (playerBars * (barW + barGap)) / 2); const opponentSplashLandX = isAnchored && opponentBars > 0 ? opponentLayout.splashX : opponentAvatarX != null - ? opponentAvatarX + opponentBarDir * AVATAR_BAR_OFFSET + ? opponentAvatarX + opponentBarDir * avatarBarOffset : dividerX + opponentDir * (28 + (opponentBars * (barW + barGap)) / 2); const playerTextX = isAnchored ? (playerBarDir < 0 ? FAR_LEFT_X : FAR_RIGHT_X) @@ -289,6 +319,7 @@ export function useBarBattleViewModel({ redGrad, battleClip, isAnchored, + isPenalty, barW, barH, barGap, @@ -305,6 +336,8 @@ export function useBarBattleViewModel({ minBars, playerBarDir, opponentBarDir, + playerKeeperShieldCenterX, + opponentKeeperShieldCenterX, playerLayout, opponentLayout, playerStackCount, diff --git a/src/features/possession/components/live-special/shared.tsx b/src/features/possession/components/live-special/shared.tsx index 99b0e361..2ec928d9 100644 --- a/src/features/possession/components/live-special/shared.tsx +++ b/src/features/possession/components/live-special/shared.tsx @@ -35,6 +35,11 @@ export interface LiveSpecialQuestionPanelProps { matchId: string; qIndex: number; totalQuestions: number; + /** Penalty round display: counter shows penalty rounds instead of question x/y. */ + isPenaltyPhase?: boolean; + penaltyDisplayRound?: number; + penaltyDisplayTotal?: number; + isPenaltySuddenDeath?: boolean; question: LiveSpecialQuestion; showOptions: boolean; timeRemaining: number; diff --git a/src/features/possession/components/pitch/PitchHtmlActors.tsx b/src/features/possession/components/pitch/PitchHtmlActors.tsx index 15f228f7..417f9bc8 100644 --- a/src/features/possession/components/pitch/PitchHtmlActors.tsx +++ b/src/features/possession/components/pitch/PitchHtmlActors.tsx @@ -28,7 +28,8 @@ interface PitchHtmlActorsProps { opponentAvatarAlt: string; playerAvatarCustomization: AvatarCustomization | null; opponentAvatarCustomization: AvatarCustomization | null; - mirrored: boolean; + playerFlipX: boolean; + opponentFlipX: boolean; isPortrait: boolean; hideBall: boolean; ballOpacity: number; @@ -53,7 +54,8 @@ export function PitchHtmlActors({ opponentAvatarAlt, playerAvatarCustomization, opponentAvatarCustomization, - mirrored, + playerFlipX, + opponentFlipX, isPortrait, hideBall, ballOpacity, @@ -99,7 +101,7 @@ export function PitchHtmlActors({
@@ -127,7 +129,7 @@ export function PitchHtmlActors({
diff --git a/src/features/possession/components/pitch/usePitchSceneModel.ts b/src/features/possession/components/pitch/usePitchSceneModel.ts index 25d2f13e..6f289c41 100644 --- a/src/features/possession/components/pitch/usePitchSceneModel.ts +++ b/src/features/possession/components/pitch/usePitchSceneModel.ts @@ -20,6 +20,7 @@ import { PENALTY_BALL_SAVE_FLIGHT_MS, PENALTY_KICK_CONTACT_MS, } from '../../realtimePossession.helpers'; +import { PENALTY_KEEPER_SHIELD_OFFSET } from '../bar-battle/barBattle.helpers'; import type { GoalCoordinates, PitchVisualizationProps } from './pitch.types'; import { @@ -233,6 +234,8 @@ export function usePitchSceneModel({ // Keeper jolt direction (away from goal) — enhanced amplitude for cinematic feel const keeperJolt = { x: [0, -8 * goal.inward, 4 * goal.inward, 0], y: [0, -6, -2, 0] }; + const penaltySaveShieldX = keeperX - PENALTY_KEEPER_SHIELD_OFFSET * goal.inward; + const penaltySaveDeflectX = penaltySaveShieldX - 54 * goal.inward; const ballTarget = useMemo(() => { if (isGoal) { return { @@ -242,8 +245,8 @@ export function usePitchSceneModel({ } if (isSave) { return { - x: [penaltyBallStart.x, penaltyBallStart.x, goal.saveTarget.x - 10 * goal.inward], - y: [penaltyBallStart.y, penaltyBallStart.y, goal.saveTarget.y + 5], + x: [penaltyBallStart.x, penaltyBallStart.x, penaltySaveShieldX, penaltySaveDeflectX], + y: [penaltyBallStart.y, penaltyBallStart.y, goal.saveTarget.y + 2, goal.saveTarget.y + 24], }; } if (useSimpleShotAnimation && isShotGoal) return simpleShotTarget; @@ -335,7 +338,7 @@ export function usePitchSceneModel({ } // Position ball at the boundary between blue and red zones return { x: possessionBoundaryX, y: 112 }; - }, [isGoal, isSave, useSimpleShotAnimation, isShotGoal, simpleShotTarget, isShotSave, isShotMiss, isPenalty, isShot, shotMode, shotVariant, goal, penaltyBallStart, shotBallOriginX, shotBallOriginY, simpleShotOriginX, simpleShotOriginY, possessionBoundaryX]); + }, [isGoal, isSave, useSimpleShotAnimation, isShotGoal, simpleShotTarget, isShotSave, isShotMiss, isPenalty, isShot, shotMode, shotVariant, goal, penaltyBallStart, penaltySaveShieldX, penaltySaveDeflectX, shotBallOriginX, shotBallOriginY, simpleShotOriginX, simpleShotOriginY, possessionBoundaryX]); const ballTransition = useMemo(() => { // Penalty: hold the ball still through the shooter's wind-up, then // launch exactly on the same contact beat as the SFX. @@ -348,7 +351,7 @@ export function usePitchSceneModel({ if (isSave) return { duration: PENALTY_BALL_SAVE_FLIGHT_MS / 1000, delay: PENALTY_KICK_CONTACT_MS / 1000, - times: [0, 0.5, 1], + times: [0, 0.44, 0.62, 1], ease: [0.3, 0, 0.2, 1] as const, }; if (useSimpleShotAnimation && simpleShotReturnToCenter) { @@ -449,6 +452,12 @@ export function usePitchSceneModel({ const opponentHtmlIsKeeper = isPenalty ? penaltyMode.isPlayerShooter : (isShot && shotMode ? shotMode.isPlayerAttacker : false); + // Avatar facing. Base art faces RIGHT; scaleX(-1) faces it LEFT. In penalties + // facing is by role, not by half: the keeper (in goal, left) faces right toward + // the ball, the shooter (pen spot, right) faces left toward goal. Outside + // penalties, fall back to the half-based mirror used in open play. + const playerFlipX = isPenalty ? playerHtmlIsShooter : mirrored; + const opponentFlipX = isPenalty ? opponentHtmlIsShooter : !mirrored; const htmlActorMotion = (isShooter: boolean, isKeeper: boolean) => { if (htmlActorResultActive && isShooter) { if (disableShotActorResultMotion && isShot && !isPenalty) { @@ -533,6 +542,8 @@ export function usePitchSceneModel({ useMarkerActors, playerHtmlIsShooter, opponentHtmlIsShooter, + playerFlipX, + opponentFlipX, playerHtmlActorMotion, opponentHtmlActorMotion, playerHtmlActorTransition, diff --git a/src/features/possession/hooks/__tests__/useBarBattle.test.ts b/src/features/possession/hooks/__tests__/useBarBattle.test.ts index 6b93aea4..134d3d8a 100644 --- a/src/features/possession/hooks/__tests__/useBarBattle.test.ts +++ b/src/features/possession/hooks/__tests__/useBarBattle.test.ts @@ -11,12 +11,17 @@ import { const MATCH_ID = 'match-1'; -function makePlayer(pointsEarned: number, isCorrect: boolean): MatchRoundResultPlayer { +function makePlayer( + pointsEarned: number, + isCorrect: boolean, + possessionPointsEarned = pointsEarned +): MatchRoundResultPlayer { return { selectedIndex: null, isCorrect, timeMs: 3000, pointsEarned, + possessionPointsEarned, totalPoints: pointsEarned, submittedOrderIds: [], }; @@ -73,6 +78,23 @@ function makeRoundResult( }; } +function makePenaltyRoundResult( + playerPoints: number, + opponentPoints: number, + penaltyOutcome: 'goal' | 'saved' +): MatchRoundResultPayload { + return { + ...makeRoundResult(playerPoints, opponentPoints), + phaseKind: 'penalty', + phaseRound: 3, + deltas: { + possessionDelta: 0, + goalScoredBySeat: null, + penaltyOutcome, + }, + }; +} + describe('useBarBattle', () => { beforeEach(() => { vi.useFakeTimers(); @@ -362,6 +384,196 @@ describe('useBarBattle', () => { }); }); + it('carries penalty save outcome into the charge animation state', async () => { + const myRound = makePlayer(50, true); + const opponentRound = makePlayer(0, false); + const roundResult = makePenaltyRoundResult(50, 0, 'saved'); + + const { result } = renderHook(() => useBarBattle({ + answerAck: { + matchId: MATCH_ID, + qIndex: 5, + questionKind: 'multipleChoice', + selectedIndex: 0, + isCorrect: true, + myTotalPoints: 50, + oppAnswered: true, + pointsEarned: 50, + phaseKind: 'penalty', + phaseRound: 3, + }, + opponentAnswered: true, + opponentRecentPoints: 0, + opponentAnsweredCorrectly: false, + roundResult, + myRound, + opponentRound, + phaseKind: 'penalty', + dividerX: 250, + })); + + await act(async () => {}); + + act(() => { + vi.advanceTimersByTime(950); + }); + + expect(result.current).toMatchObject({ + phase: 'charge', + penaltyOutcome: 'saved', + playerBars: 5, + remainingDelta: 5, + }); + + act(() => { + vi.advanceTimersByTime(1300); + }); + + expect(result.current).toMatchObject({ + phase: 'result', + penaltyOutcome: 'saved', + playerBars: 5, + remainingDelta: 5, + }); + }); + + it('keeps zero-zero penalty resolution alive for score-flight targets', async () => { + const myRound = makePlayer(0, false); + const opponentRound = makePlayer(0, false); + const roundResult = makePenaltyRoundResult(0, 0, 'saved'); + + const { result } = renderHook(() => useBarBattle({ + answerAck: { + matchId: MATCH_ID, + qIndex: 5, + questionKind: 'multipleChoice', + selectedIndex: 1, + isCorrect: false, + myTotalPoints: 0, + oppAnswered: true, + pointsEarned: 0, + phaseKind: 'penalty', + phaseRound: 3, + }, + opponentAnswered: true, + opponentRecentPoints: 0, + opponentAnsweredCorrectly: false, + roundResult, + myRound, + opponentRound, + phaseKind: 'penalty', + dividerX: 250, + })); + + await act(async () => {}); + + expect(result.current).toMatchObject({ + phase: 'both-score', + playerPoints: 0, + opponentPoints: 0, + playerBars: 0, + opponentBars: 0, + remainingDelta: 0, + penaltyOutcome: 'saved', + }); + }); + + it('uses boosted possession points for bar battle resolution while preserving base points', async () => { + const myRound = makePlayer(80, true); + const opponentRound = makePlayer(80, true, 160); + const roundResult = makeRoundResult(80, 80); + roundResult.players.opp = opponentRound; + roundResult.deltas = { + possessionDelta: -80, + goalScoredBySeat: null, + penaltyOutcome: null, + speedStreakBoostedSeat: 2, + }; + + const { result } = renderHook(() => useBarBattle({ + answerAck: { + matchId: MATCH_ID, + qIndex: 5, + questionKind: 'multipleChoice', + selectedIndex: 0, + isCorrect: true, + myTotalPoints: 80, + oppAnswered: true, + pointsEarned: 80, + phaseKind: 'normal', + phaseRound: 6, + }, + opponentAnswered: true, + opponentRecentPoints: 80, + opponentAnsweredCorrectly: true, + roundResult, + myRound, + opponentRound, + phaseKind: 'normal', + dividerX: 250, + mySeat: 1, + })); + + await act(async () => {}); + + expect(result.current).toMatchObject({ + phase: 'both-score', + playerPoints: 80, + opponentPoints: 160, + playerBars: 8, + opponentBars: 16, + remainingDelta: -8, + }); + }); + + it('ignores stray possession points when no speed-streak boost fired', async () => { + const myRound = makePlayer(100, true); + const opponentRound = makePlayer(10, true, 20); + const roundResult = makeRoundResult(100, 10); + roundResult.players.opp = opponentRound; + roundResult.deltas = { + possessionDelta: 90, + goalScoredBySeat: null, + penaltyOutcome: null, + speedStreakBoostedSeat: null, + }; + + const { result } = renderHook(() => useBarBattle({ + answerAck: { + matchId: MATCH_ID, + qIndex: 5, + questionKind: 'multipleChoice', + selectedIndex: 0, + isCorrect: true, + myTotalPoints: 100, + oppAnswered: true, + pointsEarned: 100, + phaseKind: 'normal', + phaseRound: 6, + }, + opponentAnswered: true, + opponentRecentPoints: 10, + opponentAnsweredCorrectly: true, + roundResult, + myRound, + opponentRound, + phaseKind: 'normal', + dividerX: 250, + mySeat: 1, + })); + + await act(async () => {}); + + expect(result.current).toMatchObject({ + phase: 'both-score', + playerPoints: 100, + opponentPoints: 10, + playerBars: 10, + opponentBars: 1, + remainingDelta: 9, + }); + }); + it('runs the bar-battle sequence for extra-question last-attack rounds', async () => { const myRound = makePlayer(90, true); const opponentRound = makePlayer(20, true); diff --git a/src/features/possession/hooks/__tests__/usePossessionBarBattleFlights.test.ts b/src/features/possession/hooks/__tests__/usePossessionBarBattleFlights.test.ts index 4146abb3..e52d3349 100644 --- a/src/features/possession/hooks/__tests__/usePossessionBarBattleFlights.test.ts +++ b/src/features/possession/hooks/__tests__/usePossessionBarBattleFlights.test.ts @@ -100,7 +100,215 @@ describe('usePossessionBarBattleFlights', () => { }); }); - it('fires the opponent score flight from a penalty round result when opponent_answered is not emitted', async () => { + it('fires the local penalty score flight immediately from answer_ack', async () => { + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 13, + total: 20, + phaseKind: 'penalty', + phaseRound: 1, + shooterSeat: 1, + question: { + kind: 'multipleChoice', + id: 'penalty-ack-q', + prompt: 'Penalty question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + answerAck: { + matchId: MATCH_ID, + qIndex: 13, + questionKind: 'multipleChoice', + selectedIndex: 0, + isCorrect: true, + correctIndex: 0, + myTotalPoints: 90, + oppAnswered: false, + pointsEarned: 90, + phaseKind: 'penalty', + phaseRound: 1, + }, + possessionState: { + phaseKind: 'penalty', + shooterSeat: 1, + }, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(result.current.flights).toHaveLength(1); + expect(result.current.flights[0]).toMatchObject({ + side: 'player', + points: 90, + failed: false, + }); + }); + + it('fires a local penalty +0 miss immediately from answer_ack', async () => { + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 14, + total: 20, + phaseKind: 'penalty', + phaseRound: 1, + shooterSeat: 1, + question: { + kind: 'multipleChoice', + id: 'penalty-ack-zero-q', + prompt: 'Penalty question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + answerAck: { + matchId: MATCH_ID, + qIndex: 14, + questionKind: 'multipleChoice', + selectedIndex: 1, + isCorrect: false, + correctIndex: 0, + myTotalPoints: 0, + oppAnswered: false, + pointsEarned: 0, + phaseKind: 'penalty', + phaseRound: 1, + }, + possessionState: { + phaseKind: 'penalty', + shooterSeat: 1, + }, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(result.current.flights).toHaveLength(1); + expect(result.current.flights[0]).toMatchObject({ + side: 'player', + points: 0, + failed: true, + }); + }); + + it('fires the opponent penalty score flight immediately from opponent_answered', async () => { + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 15, + total: 20, + phaseKind: 'penalty', + phaseRound: 1, + shooterSeat: 2, + question: { + kind: 'multipleChoice', + id: 'penalty-opponent-ack-q', + prompt: 'Penalty question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + possessionState: { + phaseKind: 'penalty', + shooterSeat: 2, + }, + opponentAnswered: true, + opponentAnsweredCorrectly: true, + opponentRecentPoints: 90, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(result.current.flights).toHaveLength(1); + expect(result.current.flights[0]).toMatchObject({ + side: 'opponent', + points: 90, + failed: false, + }); + }); + + it('fires the opponent penalty +0 miss immediately from opponent_answered', async () => { + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 16, + total: 20, + phaseKind: 'penalty', + phaseRound: 1, + shooterSeat: 2, + question: { + kind: 'multipleChoice', + id: 'penalty-opponent-ack-zero-q', + prompt: 'Penalty question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + possessionState: { + phaseKind: 'penalty', + shooterSeat: 2, + }, + opponentAnswered: true, + opponentAnsweredCorrectly: false, + opponentRecentPoints: 0, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(result.current.flights).toHaveLength(1); + expect(result.current.flights[0]).toMatchObject({ + side: 'opponent', + points: 0, + failed: true, + }); + }); + + it('fires penalty round-result flights when opponent_answered is not emitted', async () => { useRealtimeMatchStore.getState().setSelfUserId('user-a'); useRealtimeMatchStore.setState({ match: { @@ -170,11 +378,336 @@ describe('usePossessionBarBattleFlights', () => { vi.advanceTimersByTime(250); }); + expect(result.current.flights).toHaveLength(2); + expect(result.current.flights).toEqual(expect.arrayContaining([ + expect.objectContaining({ + side: 'player', + points: 0, + failed: true, + }), + expect.objectContaining({ + side: 'opponent', + points: 90, + failed: undefined, + }), + ])); + }); + + it('fires failed +0 flights for both sides on a zero-zero penalty round result', async () => { + useRealtimeMatchStore.getState().setSelfUserId('user-a'); + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 21, + total: 20, + phaseKind: 'penalty', + phaseRound: 2, + shooterSeat: 1, + question: { + kind: 'multipleChoice', + id: 'penalty-q-zero', + prompt: 'Penalty question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + lastRoundResult: { + matchId: MATCH_ID, + qIndex: 21, + questionKind: 'multipleChoice', + reveal: { kind: 'multipleChoice', correctIndex: 0 }, + players: { + 'user-a': { + selectedIndex: 1, + isCorrect: false, + timeMs: 3000, + pointsEarned: 0, + totalPoints: 100, + submittedOrderIds: [], + }, + 'user-b': { + selectedIndex: 2, + isCorrect: false, + timeMs: 3200, + pointsEarned: 0, + totalPoints: 100, + submittedOrderIds: [], + }, + }, + phaseKind: 'penalty', + phaseRound: 2, + shooterSeat: 1, + deltas: { + possessionDelta: 0, + goalScoredBySeat: null, + penaltyOutcome: 'saved', + }, + }, + possessionState: { + phaseKind: 'penalty', + shooterSeat: 1, + }, + opponentAnswered: false, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(result.current.flights).toHaveLength(2); + expect(result.current.flights).toEqual(expect.arrayContaining([ + expect.objectContaining({ + side: 'player', + points: 0, + failed: true, + }), + expect.objectContaining({ + side: 'opponent', + points: 0, + failed: true, + }), + ])); + }); + + it('flies the 2x badge token to the opponent slot when the opponent newly earns the streak', async () => { + appendAnchor('data-speed-streak-slot', 'opponent', rect(430, 70, 1, 1)); + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 7, + total: 12, + phaseKind: 'normal', + phaseRound: 8, + question: { + kind: 'multipleChoice', + id: 'q-7', + prompt: 'Question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + possessionState: { + phaseKind: 'normal', + speedStreakHolderSeat: null, + }, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => {}); + + act(() => { + const currentMatch = useRealtimeMatchStore.getState().match; + useRealtimeMatchStore.setState({ + match: currentMatch + ? ({ + ...currentMatch, + possessionState: { + ...(currentMatch.possessionState as object), + speedStreakHolderSeat: 2, + }, + } as never) + : currentMatch, + }); + }); + + await act(async () => {}); + expect(result.current.flights).toHaveLength(1); expect(result.current.flights[0]).toMatchObject({ side: 'opponent', - points: 90, - failed: undefined, + kind: 'badge', + points: 0, + }); + }); + + it('routes opponent score flights through the opponent 2x badge when visible', async () => { + appendAnchor('data-speed-streak-badge', 'opponent', rect(430, 70, 48, 28)); + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 8, + total: 12, + phaseKind: 'normal', + phaseRound: 9, + question: { + kind: 'multipleChoice', + id: 'q-8', + prompt: 'Question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + possessionState: { + phaseKind: 'normal', + speedStreakHolderSeat: 2, + }, + opponentAnswered: true, + opponentAnsweredCorrectly: true, + opponentRecentPoints: 80, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(result.current.flights).toHaveLength(1); + expect(result.current.flights[0]).toMatchObject({ + side: 'opponent', + points: 80, + boostVia: { x: 454, y: 84 }, + }); + }); + + it('uses doubled opponent points directly when the active 2x badge is not visible yet', async () => { + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 9, + total: 12, + phaseKind: 'normal', + phaseRound: 10, + question: { + kind: 'multipleChoice', + id: 'q-9', + prompt: 'Question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + possessionState: { + phaseKind: 'normal', + speedStreakHolderSeat: 2, + }, + opponentAnswered: true, + opponentAnsweredCorrectly: true, + opponentRecentPoints: 10, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(result.current.flights).toHaveLength(1); + expect(result.current.flights[0]).toMatchObject({ + side: 'opponent', + points: 20, + boostVia: undefined, + }); + }); + + it('routes boosted round-result fallback flights with base points to avoid double-doubling', async () => { + appendAnchor('data-speed-streak-badge', 'opponent', rect(430, 70, 48, 28)); + useRealtimeMatchStore.getState().setSelfUserId('user-a'); + useRealtimeMatchStore.setState({ + match: { + variant: 'ranked_sim', + matchId: MATCH_ID, + mySeat: 1, + currentQuestionPhase: 'playing', + currentQuestion: { + matchId: MATCH_ID, + qIndex: 10, + total: 12, + phaseKind: 'normal', + phaseRound: 11, + question: { + kind: 'multipleChoice', + id: 'q-10', + prompt: 'Question', + options: ['A', 'B', 'C', 'D'], + categoryName: 'General', + }, + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + }, + lastRoundResult: { + matchId: MATCH_ID, + qIndex: 10, + questionKind: 'multipleChoice', + reveal: { kind: 'multipleChoice', correctIndex: 0 }, + players: { + 'user-a': { + selectedIndex: 0, + isCorrect: true, + timeMs: 1600, + pointsEarned: 100, + possessionPointsEarned: 100, + totalPoints: 100, + submittedOrderIds: [], + }, + 'user-b': { + selectedIndex: 0, + isCorrect: true, + timeMs: 5000, + pointsEarned: 10, + possessionPointsEarned: 20, + totalPoints: 10, + submittedOrderIds: [], + }, + }, + phaseKind: 'normal', + phaseRound: 11, + deltas: { + possessionDelta: 80, + goalScoredBySeat: null, + penaltyOutcome: null, + speedStreakBoostedSeat: 2, + }, + }, + possessionState: { + phaseKind: 'normal', + speedStreakHolderSeat: 2, + }, + opponentAnswered: false, + } as never, + }); + + const { result } = renderHook(() => usePossessionBarBattleFlights()); + + await act(async () => { + vi.advanceTimersByTime(250); }); + + expect(result.current.flights).toEqual(expect.arrayContaining([ + expect.objectContaining({ + side: 'opponent', + points: 10, + boostVia: { x: 454, y: 84 }, + }), + ])); }); }); diff --git a/src/features/possession/hooks/__tests__/usePossessionMatchSounds.test.ts b/src/features/possession/hooks/__tests__/usePossessionMatchSounds.test.ts new file mode 100644 index 00000000..9730a906 --- /dev/null +++ b/src/features/possession/hooks/__tests__/usePossessionMatchSounds.test.ts @@ -0,0 +1,67 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatchRoundResultPayload } from '@/lib/realtime/socket.types'; +import { + PENALTY_KICK_CONTACT_MS, + PENALTY_SCORE_FLIGHT_HANDOFF_MS, +} from '../../realtimePossession.helpers'; +import { usePossessionMatchSounds } from '../usePossessionMatchSounds'; + +function makePenaltyRoundResult(qIndex = 13): MatchRoundResultPayload { + return { + matchId: 'match-1', + qIndex, + questionKind: 'multipleChoice', + reveal: { + kind: 'multipleChoice', + correctIndex: 0, + }, + players: {}, + phaseKind: 'penalty', + phaseRound: 1, + shooterSeat: 1, + attackerSeat: null, + deltas: { + possessionDelta: 0, + penaltyOutcome: 'goal', + goalScoredBySeat: 1, + }, + }; +} + +describe('usePossessionMatchSounds', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not duplicate a delayed penalty kick sound when the same round result is re-emitted', () => { + const playSfx = vi.fn(); + const { rerender } = renderHook((props: { roundResult: MatchRoundResultPayload | null }) => { + usePossessionMatchSounds({ + phase: 'PENALTY_SHOOTOUT', + answerAck: null, + roundResult: props.roundResult, + devPossessionAnimation: null, + playSfx, + }); + }, { + initialProps: { + roundResult: null as MatchRoundResultPayload | null, + }, + }); + + rerender({ roundResult: makePenaltyRoundResult() }); + rerender({ roundResult: makePenaltyRoundResult() }); + + act(() => { + vi.advanceTimersByTime(PENALTY_SCORE_FLIGHT_HANDOFF_MS + PENALTY_KICK_CONTACT_MS + 1); + }); + + expect(playSfx).toHaveBeenCalledTimes(1); + expect(playSfx).toHaveBeenCalledWith('kick'); + }); +}); diff --git a/src/features/possession/hooks/useBarBattle.ts b/src/features/possession/hooks/useBarBattle.ts index 36498ca7..4601f8c2 100644 --- a/src/features/possession/hooks/useBarBattle.ts +++ b/src/features/possession/hooks/useBarBattle.ts @@ -8,6 +8,7 @@ import type { } from '@/lib/realtime/socket.types'; import { useRealtimeMatchStore } from '@/stores/realtimeMatch.store'; import type { BarBattleState } from '../components/BarBattleOverlay'; +import { PENALTY_RESULT_DISPLAY_DELAY_MS } from '../realtimePossession.helpers'; // ─── Timing constants (ms) ────────────────────────────────────────────────── // @@ -37,9 +38,12 @@ const DONE_LINGER_MS = 100; const UNOPPOSED_PULSE_RESULT_HOLD_MS = 80; const UNOPPOSED_PULSE_DONE_LINGER_MS = 40; const RANKED_SCORE_FLIGHT_HANDOFF_MS = 420; +const PENALTY_SAVE_SHIELD_RESULT_BUFFER_MS = 180; +const PENALTY_SAVE_SHIELD_RESULT_HOLD_MS = + PENALTY_RESULT_DISPLAY_DELAY_MS - CHARGE_SHOT_OVERLAP_MS + PENALTY_SAVE_SHIELD_RESULT_BUFFER_MS; const POINTS_PER_BAR = 10; -const MAX_BARS = 12; +const MAX_BARS = 20; const BAR_BATTLE_LOCK_BUFFER_MS = 240; const UNOPPOSED_PULSE_LOCK_BUFFER_MS = 60; @@ -51,6 +55,30 @@ export function pointsToBars(points: number): number { interface BarBattleTimingOptions { includeScoreFlightHandoff?: boolean; includeUnopposedPulse?: boolean; + holdPenaltySaveShield?: boolean; +} + +export type BarBattleSide = 'player' | 'opponent'; + +export function seatForBarBattleSide( + side: BarBattleSide, + mySeat: number | null | undefined, +): 1 | 2 | null { + if (mySeat !== 1 && mySeat !== 2) return null; + if (side === 'player') return mySeat; + return mySeat === 1 ? 2 : 1; +} + +export function shouldUsePossessionPointsForSide(params: { + phaseKind?: string | null; + speedStreakBoostedSeat?: 1 | 2 | null; + mySeat?: number | null; + side: BarBattleSide; +}): boolean { + const { phaseKind, speedStreakBoostedSeat, mySeat, side } = params; + if (phaseKind !== 'normal') return false; + if (speedStreakBoostedSeat !== 1 && speedStreakBoostedSeat !== 2) return false; + return seatForBarBattleSide(side, mySeat) === speedStreakBoostedSeat; } function getScoreHandoffMs(includeScoreFlightHandoff = false): number { @@ -59,6 +87,12 @@ function getScoreHandoffMs(includeScoreFlightHandoff = false): number { : BOTH_SCORE_HOLD_MS; } +function getResultHoldMs(options: BarBattleTimingOptions): number { + if (options.includeUnopposedPulse) return UNOPPOSED_PULSE_RESULT_HOLD_MS; + if (options.holdPenaltySaveShield) return Math.max(RESULT_HOLD_MS, PENALTY_SAVE_SHIELD_RESULT_HOLD_MS); + return RESULT_HOLD_MS; +} + export function getBarBattleTotalMs( playerPoints: number, opponentPoints: number, @@ -87,7 +121,10 @@ function getBarBattleTimelineMs( const chargeMs = (includeShotCharge || shouldPulseUnopposed) && survivorCount > 0 ? CHARGE_BASE_MS + survivorCount * CHARGE_PER_BAR_MS : 0; - const resultHoldMs = shouldPulseUnopposed ? UNOPPOSED_PULSE_RESULT_HOLD_MS : RESULT_HOLD_MS; + const resultHoldMs = getResultHoldMs({ + includeUnopposedPulse: shouldPulseUnopposed, + holdPenaltySaveShield: options.holdPenaltySaveShield, + }); const doneLingerMs = shouldPulseUnopposed ? UNOPPOSED_PULSE_DONE_LINGER_MS : DONE_LINGER_MS; const scoreHandoffMs = getScoreHandoffMs(options.includeScoreFlightHandoff); const beforeResultMs = ( @@ -136,17 +173,33 @@ export function getBarBattleGoalAttackDelayMs( export function resolveBattlePoints( pointsEarned: number, questionKind?: MatchAnswerAckPayload['questionKind'] | MatchRoundResultPayload['questionKind'], - foundCount?: number + foundCount?: number, + possessionPointsEarned?: number ): number { - if (pointsEarned > 0) return pointsEarned; + const resolvedPoints = typeof possessionPointsEarned === 'number' ? possessionPointsEarned : pointsEarned; + if (resolvedPoints > 0) return resolvedPoints; if (questionKind === 'putInOrder' && typeof foundCount === 'number' && foundCount > 0) { return Math.min(foundCount, 5) * 20; } - return pointsEarned; + return resolvedPoints; +} + +export function resolvePossessionBattlePoints( + round: Pick | null | undefined, + questionKind?: MatchRoundResultPayload['questionKind'], + options: { usePossessionPoints?: boolean } = {} +): number { + const usePossessionPoints = options.usePossessionPoints ?? true; + return resolveBattlePoints( + round?.pointsEarned ?? 0, + questionKind, + round?.foundCount, + usePossessionPoints ? round?.possessionPointsEarned : undefined + ); } function isBarBattlePhaseKind(kind: string | undefined): boolean { - return kind === 'normal' || kind === 'last_attack'; + return kind === 'normal' || kind === 'last_attack' || kind === 'penalty'; } // ─── Hook ──────────────────────────────────────────────────────────────────── @@ -169,6 +222,7 @@ interface UseBarBattleParams { dividerX: number; /** Dev prototype: glow surviving one-sided bars before normal possession movement. */ unopposedBarPulse?: boolean; + mySeat?: number | null; } export function useBarBattle({ @@ -182,6 +236,7 @@ export function useBarBattle({ phaseKind, dividerX, unopposedBarPulse = false, + mySeat = null, }: UseBarBattleParams): BarBattleState | null { const matchVariant = useRealtimeMatchStore((s) => s.match?.variant); const [battle, setBattle] = useState(null); @@ -249,7 +304,7 @@ export function useBarBattle({ // Get opponent points from best available source const oppPts = opponentRound - ? resolveBattlePoints(opponentRound.pointsEarned, roundResult?.questionKind, opponentRound.foundCount) + ? resolvePossessionBattlePoints(opponentRound, roundResult?.questionKind) : (opponentRecentPoints ?? 0); scoreShownQRef.current.opponent = qIndex; @@ -290,21 +345,75 @@ export function useBarBattle({ for (const t of timersRef.current) clearTimeout(t); timersRef.current = []; - const myPts = resolveBattlePoints(myRound.pointsEarned, roundResult.questionKind, myRound.foundCount); - const oppPts = resolveBattlePoints(opponentRound.pointsEarned, roundResult.questionKind, opponentRound.foundCount); + const boostedSeat = roundResult.deltas?.speedStreakBoostedSeat ?? null; + const myPts = resolvePossessionBattlePoints(myRound, roundResult.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: kind, + speedStreakBoostedSeat: boostedSeat, + mySeat, + side: 'player', + }), + }); + const oppPts = resolvePossessionBattlePoints(opponentRound, roundResult.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: kind, + speedStreakBoostedSeat: boostedSeat, + mySeat, + side: 'opponent', + }), + }); const pBars = pointsToBars(myPts); const oBars = pointsToBars(oppPts); const delta = pBars - oBars; + + // DEBUG: trace why the bar fill can disagree with the flight number (e.g. + // flight +100/+10 but bar shows 80). Logs, per player: the raw pointsEarned + // (what the flight shows), the possessionPointsEarned (what the bar uses), + // the resolved value, the bar counts, AND the authoritative server delta — + // plus an explicit `mismatch` flag when raw and possession points diverge or + // when the bar's point-delta != raw-delta. Search console for [bar-battle-debug]. + const rawDelta = (myRound.pointsEarned ?? 0) - (opponentRound.pointsEarned ?? 0); + const possessionMismatch = + (myRound.possessionPointsEarned != null && myRound.possessionPointsEarned !== myRound.pointsEarned) || + (opponentRound.possessionPointsEarned != null && opponentRound.possessionPointsEarned !== opponentRound.pointsEarned); + console.info('[bar-battle-debug]', { + qIndex: roundResult.qIndex, + questionKind: roundResult.questionKind, + phaseKind: roundResult.phaseKind, + myRawPoints: myRound.pointsEarned, + myPossessionPoints: myRound.possessionPointsEarned, + myResolvedPts: myPts, + oppRawPoints: opponentRound.pointsEarned, + oppPossessionPoints: opponentRound.possessionPointsEarned, + oppResolvedPts: oppPts, + pBars, + oBars, + barDelta: delta, + barDeltaPoints: delta * 10, + // What the FLIGHT-shown raw scores would imply for the swing. + rawDeltaPoints: rawDelta, + // Server's authoritative possession swing for this round (the bar should match this). + serverPossessionDelta: roundResult.deltas?.possessionDelta ?? null, + // True when bar (possession) points differ from the raw points the flight shows. + possessionMismatch, + // True when the bar's point-delta doesn't equal the raw flight delta (the visible bug). + deltaMismatch: delta * 10 !== rawDelta, + }); const cancelledCount = Math.min(pBars, oBars); const key = roundResult.qIndex; const snapDividerX = dividerXRef.current; const isShotResolution = Boolean( roundResult.deltas?.goalScoredBySeat || roundResult.deltas?.penaltyOutcome ); + const penaltyOutcome = kind === 'penalty' + ? roundResult.deltas?.penaltyOutcome ?? null + : null; const shouldPulseUnopposed = unopposedBarPulse && !isShotResolution && cancelledCount === 0 && Math.max(pBars, oBars) > 0; - // If both scored 0, just clean up - if (myPts === 0 && oppPts === 0) { + // If both scored 0, there is normally no bar battle to show. Penalty + // resolutions still need a short state so the +0 flight can target the + // pitch before the save/miss animation takes over. + if (myPts === 0 && oppPts === 0 && !(kind === 'penalty' && isShotResolution)) { queueMicrotask(() => setBattle(null)); return; } @@ -318,6 +427,7 @@ export function useBarBattle({ remainingDelta: delta, dividerX: snapDividerX, chargeMode: shouldPulseUnopposed ? 'pulse' as const : 'lunge' as const, + penaltyOutcome, }; // First ensure both-score is showing with final values @@ -365,7 +475,10 @@ export function useBarBattle({ }, t) : null; - const resultHoldMs = shouldPulseUnopposed ? UNOPPOSED_PULSE_RESULT_HOLD_MS : RESULT_HOLD_MS; + const resultHoldMs = getResultHoldMs({ + includeUnopposedPulse: shouldPulseUnopposed, + holdPenaltySaveShield: penaltyOutcome === 'saved' && survivorCount > 0, + }); const doneLingerMs = shouldPulseUnopposed ? UNOPPOSED_PULSE_DONE_LINGER_MS : DONE_LINGER_MS; // Result: show remaining bars diff --git a/src/features/possession/hooks/usePossessionAnimationOrchestrator.ts b/src/features/possession/hooks/usePossessionAnimationOrchestrator.ts index 8bc88add..ac7275fa 100644 --- a/src/features/possession/hooks/usePossessionAnimationOrchestrator.ts +++ b/src/features/possession/hooks/usePossessionAnimationOrchestrator.ts @@ -17,7 +17,12 @@ import { PENALTY_ICON_SWAP_DELAY_MS, computeMyPossessionPct, } from '../realtimePossession.helpers'; -import { getBarBattleFieldLockMs, getBarBattleGoalAttackDelayMs, resolveBattlePoints } from './useBarBattle'; +import { + getBarBattleFieldLockMs, + getBarBattleGoalAttackDelayMs, + resolvePossessionBattlePoints, + shouldUsePossessionPointsForSide, +} from './useBarBattle'; import type { ShotResult } from '../types/possession.types'; interface AttackAnimation { @@ -205,16 +210,24 @@ export function usePossessionAnimationOrchestrator({ queueMicrotask(() => { setReadyRoundAttackKey(null); }); - const playerPoints = resolveBattlePoints( - myRound?.pointsEarned ?? 0, - roundResult?.questionKind, - myRound?.foundCount - ); - const opponentPoints = resolveBattlePoints( - opponentRound?.pointsEarned ?? 0, - roundResult?.questionKind, - opponentRound?.foundCount - ); + const kind = roundResult?.phaseKind ?? phaseKind; + const boostedSeat = roundResult?.deltas?.speedStreakBoostedSeat ?? null; + const playerPoints = resolvePossessionBattlePoints(myRound, roundResult?.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: kind, + speedStreakBoostedSeat: boostedSeat, + mySeat, + side: 'player', + }), + }); + const opponentPoints = resolvePossessionBattlePoints(opponentRound, roundResult?.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: kind, + speedStreakBoostedSeat: boostedSeat, + mySeat, + side: 'opponent', + }), + }); const attackDelayMs = getBarBattleGoalAttackDelayMs( playerPoints, opponentPoints, @@ -227,7 +240,7 @@ export function usePossessionAnimationOrchestrator({ }, attackDelayMs); return () => clearTimeout(timer); - }, [matchVariant, myRound, opponentRound, roundAttackKey, roundResult?.questionKind]); + }, [matchVariant, myRound, mySeat, opponentRound, phaseKind, roundAttackKey, roundResult]); const delayedRoundAttackAnimation = roundAttackKey && readyRoundAttackKey === roundAttackKey ? roundAttackAnimation @@ -350,16 +363,23 @@ export function usePossessionAnimationOrchestrator({ fieldMotionLockedRef.current = true; setFieldMotionLocked(true); if (fieldReleaseTimerRef.current) clearTimeout(fieldReleaseTimerRef.current); - const playerPoints = resolveBattlePoints( - myRound?.pointsEarned ?? 0, - roundResult.questionKind, - myRound?.foundCount - ); - const opponentPoints = resolveBattlePoints( - opponentRound?.pointsEarned ?? 0, - roundResult.questionKind, - opponentRound?.foundCount - ); + const boostedSeat = roundResult.deltas?.speedStreakBoostedSeat ?? null; + const playerPoints = resolvePossessionBattlePoints(myRound, roundResult.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: kind, + speedStreakBoostedSeat: boostedSeat, + mySeat, + side: 'player', + }), + }); + const opponentPoints = resolvePossessionBattlePoints(opponentRound, roundResult.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: kind, + speedStreakBoostedSeat: boostedSeat, + mySeat, + side: 'opponent', + }), + }); const fieldLockMs = Math.max( FIELD_RESULT_COMPARE_MS + FIELD_POSSESSION_CUE_MS, getBarBattleFieldLockMs(playerPoints, opponentPoints, { @@ -374,7 +394,7 @@ export function usePossessionAnimationOrchestrator({ setMyPossessionPct(latestPossessionRef.current); fieldReleaseTimerRef.current = null; }, fieldLockMs); - }, [matchVariant, myRound, opponentRound, phaseKind, roundResult, unopposedBarPulse]); + }, [matchVariant, myRound, mySeat, opponentRound, phaseKind, roundResult, unopposedBarPulse]); useEffect(() => { delayedFieldQRef.current = null; diff --git a/src/features/possession/hooks/usePossessionBarBattleFlights.ts b/src/features/possession/hooks/usePossessionBarBattleFlights.ts index 50c2b8c9..8fee94b7 100644 --- a/src/features/possession/hooks/usePossessionBarBattleFlights.ts +++ b/src/features/possession/hooks/usePossessionBarBattleFlights.ts @@ -11,12 +11,12 @@ * arrives. * * Triggers (when `match.variant === 'ranked_sim'`): - * - Player flight: fires when `answerAck` arrives with `pointsEarned > 0`, + * - Player flight: fires when `answerAck` arrives with the local score, * gated on phaseKind === normal, last_attack, or penalty. * Deduped by `answerAck.qIndex`. - * - Opponent flight: fires when `opponentAnswered` flips true with - * `opponentAnsweredCorrectly === true && opponentRecentPoints > 0`, - * gated similarly and delayed until the local question is playable. + * - Opponent flight: fires when `opponentAnswered` flips true with the + * opponent's local score, gated similarly and delayed until the local + * question is playable. * Deduped by the current question's qIndex. * * Returns the active flights list + handlers ready to feed into @@ -34,6 +34,7 @@ import { useShallow } from 'zustand/shallow'; import { useRealtimeMatchStore } from '@/stores/realtimeMatch.store'; import type { FlightSpec } from '../components/BarBattleFlightOverlay'; import { logger } from '@/utils/logger'; +import { shouldUsePossessionPointsForSide } from './useBarBattle'; import type { MatchAnswerAckPayload, MatchRoundResultPayload, @@ -41,7 +42,7 @@ import type { type Side = 'player' | 'opponent'; -function resolveFlightPoints( +function resolveBaseFlightPoints( pointsEarned: number, questionKind?: MatchAnswerAckPayload['questionKind'] | MatchRoundResultPayload['questionKind'], foundCount?: number @@ -53,6 +54,22 @@ function resolveFlightPoints( return pointsEarned; } +function resolvePossessionFlightPoints( + pointsEarned: number, + questionKind?: MatchAnswerAckPayload['questionKind'] | MatchRoundResultPayload['questionKind'], + foundCount?: number, + possessionPointsEarned?: number +): { basePoints: number; possessionPoints: number } { + const basePoints = resolveBaseFlightPoints(pointsEarned, questionKind, foundCount); + if (typeof possessionPointsEarned !== 'number') { + return { basePoints, possessionPoints: basePoints }; + } + return { + basePoints, + possessionPoints: resolveBaseFlightPoints(possessionPointsEarned, questionKind, foundCount), + }; +} + function isFlightPhaseKind(kind: string | undefined): boolean { return kind === 'normal' || kind === 'last_attack' || kind === 'penalty'; } @@ -150,6 +167,20 @@ function rectCentre(rect: DOMRect): { x: number; y: number } { }; } +function sideForSeat(seat: 1 | 2 | null, mySeat: number | null): Side | null { + if (seat == null || mySeat == null) return null; + return seat === mySeat ? 'player' : 'opponent'; +} + +function boostedSideForCurrentQuestion( + phaseKind: string | undefined, + holderSeat: 1 | 2 | null, + mySeat: number | null +): Side | null { + if (phaseKind !== 'normal') return null; + return sideForSeat(holderSeat, mySeat); +} + function clampFlightPoint(point: { x: number; y: number }): { x: number; y: number } { if (typeof window === 'undefined') return point; const xPadding = window.innerWidth < 640 ? 88 : 56; @@ -305,14 +336,19 @@ export function usePossessionBarBattleFlights() { opponentRect: DOMRect | null; pitchRect: DOMRect | null; points: number; + possessionPoints?: number; failed?: boolean; }) => { const id = ++flightSeqRef.current; - const addFlight = () => { - const barTargetRect = findPitchBarTarget(params.side); - // If the 2× streak badge is showing for this side, route the +N through - // it so the number visibly doubles mid-flight. - const badgeRect = !params.failed ? findSpeedStreakBadge(params.side) : null; + const addFlight = () => { + const barTargetRect = findPitchBarTarget(params.side); + const possessionPoints = params.possessionPoints ?? params.points; + const hasBoostedPoints = possessionPoints === params.points * 2 && params.points > 0; + // If the 2× streak badge is visible, route the base +N through it so the + // number doubles mid-flight. If the badge is not mounted yet, show the + // possession-resolved value directly so the landing value still matches + // the bars. + const badgeRect = !params.failed && hasBoostedPoints ? findSpeedStreakBadge(params.side) : null; setFlights((prev) => [...prev, { id, side: params.side, @@ -320,7 +356,7 @@ export function usePossessionBarBattleFlights() { target: barTargetRect ? clampFlightPoint(rectCentre(barTargetRect)) : computeFallbackBarLaneTarget(params.targetRect, params.opponentRect, params.pitchRect), - points: params.points, + points: badgeRect ? params.points : possessionPoints, failed: params.failed, boostVia: badgeRect ? clampFlightPoint(rectCentre(badgeRect)) : undefined, }]); @@ -338,6 +374,7 @@ export function usePossessionBarBattleFlights() { side: Side; roundKey: string; points: number; + possessionPoints?: number; failed?: boolean; logLabel: string; questionKind?: string; @@ -368,6 +405,7 @@ export function usePossessionBarBattleFlights() { opponentRect: otherAvatarRect, pitchRect, points: params.points, + possessionPoints: params.possessionPoints, failed: params.failed, }); return; @@ -399,10 +437,16 @@ export function usePossessionBarBattleFlights() { const answerAck = barBattleMatch.answerAck; useEffect(() => { if (!enabled || !answerAck) return; - const points = resolveFlightPoints(answerAck.pointsEarned, answerAck.questionKind, answerAck.foundCount); - const failed = !answerAck.isCorrect || points <= 0; const phaseKind = answerAck.phaseKind ?? 'normal'; if (!isFlightPhaseKind(phaseKind)) return; + const points = resolveBaseFlightPoints(answerAck.pointsEarned, answerAck.questionKind, answerAck.foundCount); + const activeBoostedSide = boostedSideForCurrentQuestion( + phaseKind, + barBattleMatch.speedStreakHolderSeat, + barBattleMatch.mySeat + ); + const possessionPoints = activeBoostedSide === 'player' ? points * 2 : points; + const failed = !answerAck.isCorrect || possessionPoints <= 0; const ackKey = `${answerAck.matchId}:${answerAck.qIndex}`; if (currentKey !== ackKey) return; if (barBattleMatch.currentQuestionPhase !== 'playing') return; @@ -412,11 +456,20 @@ export function usePossessionBarBattleFlights() { side: 'player', roundKey: ackKey, points, + possessionPoints, failed, logLabel: 'Bar-battle player flight', questionKind: answerAck.questionKind, }); - }, [barBattleMatch.currentQuestionPhase, currentKey, enabled, answerAck, enqueueFlightFromDom]); + }, [ + barBattleMatch.currentQuestionPhase, + barBattleMatch.mySeat, + barBattleMatch.speedStreakHolderSeat, + currentKey, + enabled, + answerAck, + enqueueFlightFromDom, + ]); // Fallback: if the client missed its immediate answer_ack, fire the same // player flight from the authoritative round_result so the user still sees @@ -430,45 +483,76 @@ export function usePossessionBarBattleFlights() { if (playerFiredQRef.current === roundKey) return; const playerRound = roundResult.players[selfUserId]; - const points = playerRound - ? resolveFlightPoints(playerRound.pointsEarned, roundResult.questionKind, playerRound.foundCount) - : 0; - if (!playerRound || points <= 0) return; + const usePossessionPoints = shouldUsePossessionPointsForSide({ + phaseKind, + speedStreakBoostedSeat: roundResult.deltas?.speedStreakBoostedSeat ?? null, + mySeat: barBattleMatch.mySeat, + side: 'player', + }); + const resolved = playerRound + ? resolvePossessionFlightPoints( + playerRound.pointsEarned, + roundResult.questionKind, + playerRound.foundCount, + usePossessionPoints ? playerRound.possessionPointsEarned : undefined + ) + : { basePoints: 0, possessionPoints: 0 }; + const failed = playerRound ? !playerRound.isCorrect || resolved.possessionPoints <= 0 : false; + if (!playerRound || (resolved.possessionPoints <= 0 && phaseKind !== 'penalty')) return; enqueueFlightFromDom({ side: 'player', roundKey, - points, + points: resolved.basePoints, + possessionPoints: resolved.possessionPoints, + failed: failed ? true : undefined, logLabel: 'Bar-battle player fallback flight', questionKind: roundResult.questionKind, }); - }, [enabled, enqueueFlightFromDom, phaseKindFromState, roundResult, selfUserId]); + }, [barBattleMatch.mySeat, enabled, enqueueFlightFromDom, phaseKindFromState, roundResult, selfUserId]); // Same fallback for the opponent. Special questions may only expose final // opponent scoring through round_result, so keep the flight feedback even // when match:opponent_answered was not emitted or arrived too early. useEffect(() => { if (!enabled || !roundResult || !selfUserId) return; - if (barBattleMatch.currentQuestionPhase !== 'playing') return; + // No currentQuestionPhase guard here (matching the player fallback): in + // penalties the optimistic opponent flight is intentionally skipped, so this + // round_result fallback is the ONLY opponent flight and must fire even after + // the phase has advanced past 'playing'. Deduped by opponentFiredQRef. const phaseKind = roundResult.phaseKind ?? phaseKindFromState; if (!isFlightPhaseKind(phaseKind)) return; const roundKey = `${roundResult.matchId}:${roundResult.qIndex}`; if (opponentFiredQRef.current === roundKey) return; const opponentRound = Object.entries(roundResult.players).find(([userId]) => userId !== selfUserId)?.[1]; - const points = opponentRound - ? resolveFlightPoints(opponentRound.pointsEarned, roundResult.questionKind, opponentRound.foundCount) - : 0; - if (!opponentRound || points <= 0) return; + const usePossessionPoints = shouldUsePossessionPointsForSide({ + phaseKind, + speedStreakBoostedSeat: roundResult.deltas?.speedStreakBoostedSeat ?? null, + mySeat: barBattleMatch.mySeat, + side: 'opponent', + }); + const resolved = opponentRound + ? resolvePossessionFlightPoints( + opponentRound.pointsEarned, + roundResult.questionKind, + opponentRound.foundCount, + usePossessionPoints ? opponentRound.possessionPointsEarned : undefined + ) + : { basePoints: 0, possessionPoints: 0 }; + const failed = opponentRound ? !opponentRound.isCorrect || resolved.possessionPoints <= 0 : false; + if (!opponentRound || (resolved.possessionPoints <= 0 && phaseKind !== 'penalty')) return; enqueueFlightFromDom({ side: 'opponent', roundKey, - points, + points: resolved.basePoints, + possessionPoints: resolved.possessionPoints, + failed: failed ? true : undefined, logLabel: 'Bar-battle opponent fallback flight', questionKind: roundResult.questionKind, }); - }, [barBattleMatch.currentQuestionPhase, enabled, enqueueFlightFromDom, phaseKindFromState, roundResult, selfUserId]); + }, [barBattleMatch.mySeat, enabled, enqueueFlightFromDom, phaseKindFromState, roundResult, selfUserId]); // ── Opponent flight on opponent answer ────────────────────────────────── const opponentAnswered = barBattleMatch.opponentAnswered; @@ -485,11 +569,18 @@ export function usePossessionBarBattleFlights() { if (currentKey == null) return; if (opponentFiredQRef.current === currentKey) return; - const failed = opponentAnsweredCorrectly !== true || opponentRecentPoints <= 0; + const activeBoostedSide = boostedSideForCurrentQuestion( + phaseKindFromState, + barBattleMatch.speedStreakHolderSeat, + barBattleMatch.mySeat + ); + const possessionPoints = activeBoostedSide === 'opponent' ? opponentRecentPoints * 2 : opponentRecentPoints; + const failed = opponentAnsweredCorrectly !== true || possessionPoints <= 0; enqueueFlightFromDom({ side: 'opponent', roundKey: currentKey, points: opponentRecentPoints, + possessionPoints, failed, logLabel: 'Bar-battle opponent flight', }); @@ -499,17 +590,19 @@ export function usePossessionBarBattleFlights() { opponentAnswered, opponentAnsweredCorrectly, opponentRecentPoints, + barBattleMatch.mySeat, + barBattleMatch.speedStreakHolderSeat, barBattleMatch.currentQuestionPhase, currentQIndex, currentKey, phaseKindFromState, ]); - // ── 2× badge fly-in when the player newly earns the streak ────────────── + // ── 2× badge fly-in when either side newly earns the streak ───────────── // Driven by the LIVE match-state holder (the source of truth that survives - // across questions), not the transient round result. When the holder - // transitions to my seat, fly a "2×" token from the answer source to the HUD - // badge slot, where the sticky badge takes over. + // across questions), not the transient round result. When the holder changes, + // fly a "2×" token from that side's answer source to its HUD badge slot, + // where the sticky badge takes over. const mySeat = barBattleMatch.mySeat; const liveHolderSeat = barBattleMatch.speedStreakHolderSeat; const prevHolderRef = useRef<1 | 2 | null>(null); @@ -529,17 +622,19 @@ export function usePossessionBarBattleFlights() { } const prev = prevHolderRef.current; prevHolderRef.current = liveHolderSeat; - // Only fly the token on a fresh null/opponent → me transition. - if (mySeat == null || liveHolderSeat !== mySeat || prev === mySeat) return; + // Only fly the token on a fresh transition to a real holder. + if (liveHolderSeat == null || prev === liveHolderSeat) return; + const side = sideForSeat(liveHolderSeat, mySeat); + if (!side) return; const launch = (retries: number) => { - const sourceRect = findScoreAnchor('player'); - const slotRect = findSpeedStreakSlot('player'); + const sourceRect = findScoreAnchor(side); + const slotRect = findSpeedStreakSlot(side); if (sourceRect && slotRect) { const id = ++flightSeqRef.current; setFlights((prevFlights) => [...prevFlights, { id, - side: 'player', + side, kind: 'badge', source: clampFlightPoint(rectCentre(sourceRect)), target: clampFlightPoint(rectCentre(slotRect)), diff --git a/src/features/possession/hooks/usePossessionFieldState.ts b/src/features/possession/hooks/usePossessionFieldState.ts index 8be6f77a..3b87370a 100644 --- a/src/features/possession/hooks/usePossessionFieldState.ts +++ b/src/features/possession/hooks/usePossessionFieldState.ts @@ -11,6 +11,7 @@ import type { MatchStatePayload, MatchVariant } from '@/lib/realtime/socket.type import type { DevPossessionAnimation } from '@/stores/realtimeMatch.store'; import { PitchVisualization } from '../components/PitchVisualization'; import { usePossessionAnimationOrchestrator } from './usePossessionAnimationOrchestrator'; +import { getBarBattleGoalAttackDelayMs, resolvePossessionBattlePoints } from './useBarBattle'; import { getZone } from './usePossessionMovement'; import { getQuestionDurationSeconds, @@ -37,6 +38,8 @@ export interface PossessionFieldState { oppGoals: number; myPenaltyGoals: number; oppPenaltyGoals: number; + myPenaltyAttempts: Array<'goal' | 'miss'>; + oppPenaltyAttempts: Array<'goal' | 'miss'>; questionDurationSeconds: number; zone: string; zoneColor: string; @@ -48,6 +51,8 @@ export interface PossessionFieldState { pitchProps: PitchProps; /** True when I currently hold the 2× speed streak. */ speedStreakMine: boolean; + /** True when the opponent currently holds the 2× speed streak. */ + speedStreakOpponent: boolean; } interface UsePossessionFieldStateParams { @@ -95,6 +100,45 @@ function getSeatGoals(params: { }; } +function reconstructPenaltyAttempts(goals: number, attempts: number): Array<'goal' | 'miss'> { + return [ + ...Array.from({ length: Math.max(0, goals) }, () => 'goal' as const), + ...Array.from({ length: Math.max(0, attempts - goals) }, () => 'miss' as const), + ]; +} + +function getSeatPenaltyAttempts(params: { + possessionState: MatchStatePayload | null; + mySeat: number | null; +}): { myPenaltyAttempts: Array<'goal' | 'miss'>; oppPenaltyAttempts: Array<'goal' | 'miss'> } { + const { possessionState, mySeat } = params; + if (!possessionState) return { myPenaltyAttempts: [], oppPenaltyAttempts: [] }; + + const attempts = possessionState.penaltyAttempts; + if (attempts) { + return { + myPenaltyAttempts: mySeat === 2 ? attempts.seat2 : attempts.seat1, + oppPenaltyAttempts: mySeat === 2 ? attempts.seat1 : attempts.seat2, + }; + } + + const stateWithPenalty = possessionState as MatchStatePayload & { + penalty?: { kicksTaken?: { seat1?: number; seat2?: number } }; + }; + const seat1Attempts = reconstructPenaltyAttempts( + possessionState.penaltyGoals.seat1, + Number(stateWithPenalty.penalty?.kicksTaken?.seat1 ?? possessionState.penaltyGoals.seat1) + ); + const seat2Attempts = reconstructPenaltyAttempts( + possessionState.penaltyGoals.seat2, + Number(stateWithPenalty.penalty?.kicksTaken?.seat2 ?? possessionState.penaltyGoals.seat2) + ); + return { + myPenaltyAttempts: mySeat === 2 ? seat2Attempts : seat1Attempts, + oppPenaltyAttempts: mySeat === 2 ? seat1Attempts : seat2Attempts, + }; +} + export function usePossessionFieldState({ possessionState, mySeat, @@ -134,6 +178,10 @@ export function usePossessionFieldState({ possessionState, mySeat, }), [mySeat, possessionState]); + const { myPenaltyAttempts, oppPenaltyAttempts } = useMemo(() => getSeatPenaltyAttempts({ + possessionState, + mySeat, + }), [mySeat, possessionState]); const { activeAttackAnimation, @@ -235,7 +283,14 @@ export function usePossessionFieldState({ if (!isPenaltyQuestion || !penaltyRoundResult || !myRound || !opponentRound || !immediatePenaltyResult) return 0; if (variant !== 'ranked_sim') return 0; - return PENALTY_SCORE_FLIGHT_HANDOFF_MS; + // Wait for the bar battle to play out before the shot/result, exactly like + // an open-play goal (usePossessionGoalCelebration). Using a fixed handoff + // fired the shot while the bars were still animating. + const playerPoints = resolvePossessionBattlePoints(myRound, penaltyRoundResult.questionKind); + const opponentPoints = resolvePossessionBattlePoints(opponentRound, penaltyRoundResult.questionKind); + return getBarBattleGoalAttackDelayMs(playerPoints, opponentPoints, PENALTY_SCORE_FLIGHT_HANDOFF_MS, { + includeScoreFlightHandoff: true, + }); }, [immediatePenaltyResult, isPenaltyQuestion, variant, myRound, opponentRound, penaltyRoundResult]); const penaltyResultKey = penaltyRoundResult && immediatePenaltyResult @@ -309,6 +364,23 @@ export function usePossessionFieldState({ resultShooterIsMe, ]); + const visiblePenaltyAttempts = useMemo(() => { + if (!isPenaltyQuestion || !penaltyRoundResult || !immediatePenaltyResult || penaltyDisplayResult) { + return { myPenaltyAttempts, oppPenaltyAttempts }; + } + return resultShooterIsMe + ? { myPenaltyAttempts: myPenaltyAttempts.slice(0, -1), oppPenaltyAttempts } + : { myPenaltyAttempts, oppPenaltyAttempts: oppPenaltyAttempts.slice(0, -1) }; + }, [ + immediatePenaltyResult, + isPenaltyQuestion, + myPenaltyAttempts, + oppPenaltyAttempts, + penaltyDisplayResult, + penaltyRoundResult, + resultShooterIsMe, + ]); + const uiPhase: Phase = useMemo(() => { if (isHalftime) return 'halftime'; if (isPenaltyQuestion) { @@ -385,6 +457,10 @@ export function usePossessionFieldState({ // one-shot fly-in / score-doubling moment, handled in the flight overlay. const speedStreakMine = mySeat != null && possessionState?.speedStreakHolderSeat === mySeat; + const speedStreakOpponent = + mySeat != null + && possessionState?.speedStreakHolderSeat != null + && possessionState.speedStreakHolderSeat !== mySeat; return { mySeat, @@ -402,6 +478,8 @@ export function usePossessionFieldState({ oppGoals, myPenaltyGoals: visiblePenaltyGoals.myPenaltyGoals, oppPenaltyGoals: visiblePenaltyGoals.oppPenaltyGoals, + myPenaltyAttempts: visiblePenaltyAttempts.myPenaltyAttempts, + oppPenaltyAttempts: visiblePenaltyAttempts.oppPenaltyAttempts, questionDurationSeconds, zone, zoneColor, @@ -412,5 +490,6 @@ export function usePossessionFieldState({ uiPhase, pitchProps, speedStreakMine, + speedStreakOpponent, }; } diff --git a/src/features/possession/hooks/usePossessionGoalCelebration.ts b/src/features/possession/hooks/usePossessionGoalCelebration.ts index ced0d846..02297b56 100644 --- a/src/features/possession/hooks/usePossessionGoalCelebration.ts +++ b/src/features/possession/hooks/usePossessionGoalCelebration.ts @@ -10,7 +10,11 @@ import { GOAL_SHOT_TO_CELEBRATION_MS, type GoalCelebrationState, } from '../realtimePossession.helpers'; -import { getBarBattleGoalAttackDelayMs, resolveBattlePoints } from './useBarBattle'; +import { + getBarBattleGoalAttackDelayMs, + resolvePossessionBattlePoints, + shouldUsePossessionPointsForSide, +} from './useBarBattle'; interface DevPossessionAnimationLike { id?: number; @@ -41,6 +45,7 @@ export function usePossessionGoalCelebration({ }: UsePossessionGoalCelebrationParams) { const matchVariant = useRealtimeMatchStore((s) => s.match?.variant); const selfUserId = useRealtimeMatchStore((s) => s.selfUserId); + const storeMySeat = useRealtimeMatchStore((s) => s.match?.mySeat ?? null); const [goalCelebration, setGoalCelebration] = useState(null); const goalCelebrationHideTimerRef = useRef | null>(null); const goalCelebrationStartTimerRef = useRef | null>(null); @@ -76,16 +81,24 @@ export function usePossessionGoalCelebration({ const players = roundResult?.players ?? {}; const playerRound = selfUserId ? players[selfUserId] : undefined; const opponentRound = Object.entries(players).find(([userId]) => userId !== selfUserId)?.[1]; - const playerPoints = resolveBattlePoints( - playerRound?.pointsEarned ?? 0, - roundResult?.questionKind, - playerRound?.foundCount - ); - const opponentPoints = resolveBattlePoints( - opponentRound?.pointsEarned ?? 0, - roundResult?.questionKind, - opponentRound?.foundCount - ); + const boostedSeat = roundDeltas?.speedStreakBoostedSeat ?? null; + const effectiveMySeat = mySeat ?? storeMySeat; + const playerPoints = resolvePossessionBattlePoints(playerRound, roundResult?.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: roundPhaseKind, + speedStreakBoostedSeat: boostedSeat, + mySeat: effectiveMySeat, + side: 'player', + }), + }); + const opponentPoints = resolvePossessionBattlePoints(opponentRound, roundResult?.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: roundPhaseKind, + speedStreakBoostedSeat: boostedSeat, + mySeat: effectiveMySeat, + side: 'opponent', + }), + }); const attackDelayMs = getBarBattleGoalAttackDelayMs( playerPoints, opponentPoints, @@ -117,8 +130,10 @@ export function usePossessionGoalCelebration({ roundQIndex, roundScorerSeat, roundResult, + roundDeltas, matchVariant, selfUserId, + storeMySeat, ]); useEffect(() => { diff --git a/src/features/possession/hooks/usePossessionMatchSounds.ts b/src/features/possession/hooks/usePossessionMatchSounds.ts index a275088e..e36e3feb 100644 --- a/src/features/possession/hooks/usePossessionMatchSounds.ts +++ b/src/features/possession/hooks/usePossessionMatchSounds.ts @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import type { MatchAnswerAckPayload, MatchRoundResultPayload } from '@/lib/realtime/socket.types'; import { useRealtimeMatchStore, type DevPossessionAnimation } from '@/stores/realtimeMatch.store'; import { @@ -8,9 +8,13 @@ import { PENALTY_KICK_CONTACT_MS, PENALTY_SCORE_FLIGHT_HANDOFF_MS, } from '../realtimePossession.helpers'; -import { getBarBattleGoalAttackDelayMs, resolveBattlePoints } from './useBarBattle'; +import { + getBarBattleGoalAttackDelayMs, + resolvePossessionBattlePoints, + shouldUsePossessionPointsForSide, +} from './useBarBattle'; -type PossessionSfxName = 'whistle' | 'kick' | 'pass' | 'correctRanked'; +type PossessionSfxName = 'whistle' | 'kick' | 'pass' | 'correctRanked' | 'wrongAnswer'; interface UsePossessionMatchSoundsParams { phase: string | undefined; @@ -29,6 +33,7 @@ export function usePossessionMatchSounds({ }: UsePossessionMatchSoundsParams): void { const matchVariant = useRealtimeMatchStore((s) => s.match?.variant); const selfUserId = useRealtimeMatchStore((s) => s.selfUserId); + const mySeat = useRealtimeMatchStore((s) => s.match?.mySeat ?? null); const prevPhaseRef = useRef(null); const playSfxRef = useRef(playSfx); useEffect(() => { @@ -45,17 +50,43 @@ export function usePossessionMatchSounds({ } }, [phase]); - const correctAnswerSfxKeyRef = useRef(null); + // One answer-result SFX per question: the correct chime when the player got + // it right, the wrong-answer buzzer when they didn't. Deduped by matchId+qIndex + // so re-renders / repeated acks don't retrigger it. + const answerSfxKeyRef = useRef(null); useEffect(() => { - if (!answerAck?.isCorrect) return; + if (!answerAck) return; const key = `${answerAck.matchId}:${answerAck.qIndex}`; - if (correctAnswerSfxKeyRef.current === key) return; - correctAnswerSfxKeyRef.current = key; - playSfxRef.current('correctRanked'); + if (answerSfxKeyRef.current === key) return; + answerSfxKeyRef.current = key; + playSfxRef.current(answerAck.isCorrect ? 'correctRanked' : 'wrongAnswer'); }, [answerAck]); + const roundResultSfxKeyRef = useRef(null); + const roundResultSfxTimerRef = useRef(null); + const clearRoundResultSfxTimer = useCallback(() => { + if (roundResultSfxTimerRef.current === null) return; + window.clearTimeout(roundResultSfxTimerRef.current); + roundResultSfxTimerRef.current = null; + }, []); + + useEffect(() => clearRoundResultSfxTimer, [clearRoundResultSfxTimer]); + useEffect(() => { - if (!roundResult) return; + if (!roundResult) { + clearRoundResultSfxTimer(); + return; + } + const roundResultSfxKey = [ + roundResult.matchId, + roundResult.qIndex, + roundResult.phaseKind, + roundResult.deltas?.goalScoredBySeat ?? 'no-goal', + roundResult.deltas?.penaltyOutcome ?? 'no-penalty-outcome', + ].join(':'); + if (roundResultSfxKeyRef.current === roundResultSfxKey) return; + roundResultSfxKeyRef.current = roundResultSfxKey; + clearRoundResultSfxTimer(); const phaseKindForSfx = roundResult.phaseKind; if ( phaseKindForSfx === 'penalty' @@ -72,16 +103,23 @@ export function usePossessionMatchSounds({ const players = roundResult.players ?? {}; const playerRound = selfUserId ? players[selfUserId] : undefined; const opponentRound = Object.entries(players).find(([userId]) => userId !== selfUserId)?.[1]; - const playerPoints = resolveBattlePoints( - playerRound?.pointsEarned ?? 0, - roundResult.questionKind, - playerRound?.foundCount - ); - const opponentPoints = resolveBattlePoints( - opponentRound?.pointsEarned ?? 0, - roundResult.questionKind, - opponentRound?.foundCount - ); + const boostedSeat = roundResult.deltas?.speedStreakBoostedSeat ?? null; + const playerPoints = resolvePossessionBattlePoints(playerRound, roundResult.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: phaseKindForSfx, + speedStreakBoostedSeat: boostedSeat, + mySeat, + side: 'player', + }), + }); + const opponentPoints = resolvePossessionBattlePoints(opponentRound, roundResult.questionKind, { + usePossessionPoints: shouldUsePossessionPointsForSide({ + phaseKind: phaseKindForSfx, + speedStreakBoostedSeat: boostedSeat, + mySeat, + side: 'opponent', + }), + }); // Penalty kick: roundResult first waits for score-flight handoff before // the visible avatar kick starts. Schedule the SFX from raw roundResult // to that same eventual contact beat. @@ -92,12 +130,14 @@ export function usePossessionMatchSounds({ includeScoreFlightHandoff: matchVariant === 'ranked_sim', }) : 0; - const timer = window.setTimeout(() => playSfxRef.current('kick'), kickDelayMs); - return () => window.clearTimeout(timer); + roundResultSfxTimerRef.current = window.setTimeout(() => { + roundResultSfxTimerRef.current = null; + playSfxRef.current('kick'); + }, kickDelayMs); } else { playSfxRef.current('pass'); } - }, [matchVariant, roundResult, selfUserId]); + }, [clearRoundResultSfxTimer, matchVariant, mySeat, roundResult, selfUserId]); useEffect(() => { if (!devPossessionAnimation) return; diff --git a/src/features/possession/hooks/usePossessionRoundTransition.ts b/src/features/possession/hooks/usePossessionRoundTransition.ts index 2e34ad55..6f888342 100644 --- a/src/features/possession/hooks/usePossessionRoundTransition.ts +++ b/src/features/possession/hooks/usePossessionRoundTransition.ts @@ -269,13 +269,20 @@ export function usePossessionRoundTransition({ && !isHalftime && !goalCelebration; + // Show a "Penalty N" intro between penalty rounds, mirroring the open-play + // round transition. The next penalty question may arrive as a buffered + // `pendingQuestion` OR be promoted straight to `localQuestion` (no buffering), + // so accept either — otherwise the overlay silently never fires and the + // shootout "snaps" between questions with no intro. + const hasNextPenaltyQuestion = pendingQuestion?.phaseKind === 'penalty' + || localQuestion?.phaseKind === 'penalty'; const showPenaltyTransition = !firstQuestionIntro && !penaltyCountdownActive && !goalCelebration && phase !== 'COMPLETED' && roundResultHoldDone && roundResult?.phaseKind === 'penalty' - && pendingQuestion?.phaseKind === 'penalty'; + && hasNextPenaltyQuestion; useLayoutEffect(() => { if (showRoundTransition && !transitionVisibleRef.current) { @@ -308,7 +315,10 @@ export function usePossessionRoundTransition({ if (showPenaltyTransition && !transitionVisibleRef.current) { transitionVisibleRef.current = true; + // Prefer the buffered next question, else the promoted current penalty + // question, else derive from the just-finished round. const penaltyRound = pendingQuestion?.phaseRound + ?? (localQuestion?.phaseKind === 'penalty' ? localQuestion.phaseRound : undefined) ?? (typeof roundResult?.phaseRound === 'number' ? roundResult.phaseRound + 1 : undefined) ?? 1; setTransitionSnapshot({ @@ -325,6 +335,7 @@ export function usePossessionRoundTransition({ }, [ firstQuestionIntro, half, + localQuestion?.phaseKind, localQuestion?.phaseRound, localQuestion?.qIndex, localQuestion?.question.categoryName, diff --git a/src/features/possession/hooks/useRealtimePossessionMatchController.ts b/src/features/possession/hooks/useRealtimePossessionMatchController.ts index 3a85b576..9f04742c 100644 --- a/src/features/possession/hooks/useRealtimePossessionMatchController.ts +++ b/src/features/possession/hooks/useRealtimePossessionMatchController.ts @@ -4,7 +4,9 @@ import { useCallback, useEffect, useMemo, useState, type ComponentProps } from ' import { useShallow } from 'zustand/shallow'; import { useRealtimeGameLogic } from '@/lib/match/useRealtimeGameLogic'; import { useGameSounds } from '@/lib/sounds/useGameSounds'; +import { usePreloadImages } from '@/lib/usePreloadImages'; import { useRealtimeMatchStore, type MatchQuestionState } from '@/stores/realtimeMatch.store'; +import { useRankedProfile } from '@/lib/queries/ranked.queries'; import { logger } from '@/utils/logger'; import { HalftimeScreen } from '../components/HalftimeScreen'; import type { PossessionViewportModel } from '../components/PossessionMatchViewport'; @@ -29,6 +31,7 @@ import { type FeedResult, } from '../realtimePossession.helpers'; import type { FeedDirection } from '../types/possession.types'; +import { MAX_PENALTY_ROUNDS } from '../types/possession.types'; import type { AvatarCustomization } from '@/types/game'; type HalftimeModel = ComponentProps; @@ -121,6 +124,12 @@ export function useRealtimePossessionMatchController({ const meUserId = useRealtimeMatchStore((store) => store.selfUserId); const shouldPulseUnopposedBars = unopposedBarPulse || possessionMatch.variant === 'ranked_sim'; + // RP for the halftime/penalty ban header (mirrors the pre-match draft header): + // self from the ranked profile, opponent from the match participant payload. + const { data: rankedProfile } = useRankedProfile(); + const playerRankPoints = rankedProfile?.rp ?? null; + const opponentRankPoints = possessionMatch.opponent?.rp ?? null; + const [muted, setMuted] = useState(false); const [quitModalOpen, setQuitModalOpen] = useState(false); @@ -149,9 +158,23 @@ export function useRealtimePossessionMatchController({ const localQuestionIndex = localQuestion?.qIndex ?? null; const phaseKind = localQuestion?.phaseKind ?? possessionState?.phaseKind ?? 'normal'; const halftimeActive = phase === 'HALFTIME'; + // Warm the ban-category images as soon as the halftime options arrive in the + // socket payload — usually a beat before the ban UI renders — so the cards + // don't show blank while images download. See usePreloadImages. + const banImageUrls = useMemo( + () => (possessionState?.halftime.categoryOptions ?? []).map((c) => c.imageUrl ?? null), + [possessionState?.halftime.categoryOptions], + ); + usePreloadImages(banImageUrls); const isPenaltyQuestion = phaseKind === 'penalty'; const isLastAttackQuestion = phaseKind === 'last_attack'; const isShotQuestion = phaseKind === 'shot'; + // Penalty round display: regulation counts 1..MAX_PENALTY_ROUNDS; sudden death + // restarts as its own one-shot set (round 1/1, pips reset) — see PenaltyHUD. + const isPenaltySuddenDeath = possessionState?.penaltySuddenDeath ?? false; + const penaltyRound = Math.max(1, possessionState?.phaseRound ?? 1); + const penaltyDisplayRound = isPenaltySuddenDeath ? 1 : penaltyRound; + const penaltyDisplayTotal = isPenaltySuddenDeath ? 1 : MAX_PENALTY_ROUNDS; const pendingQuestion = possessionMatch.pendingQuestion; const answerAck = possessionMatch.answerAck && possessionMatch.answerAck.qIndex === localQuestionIndex ? possessionMatch.answerAck @@ -330,6 +353,7 @@ export function useRealtimePossessionMatchController({ phaseKind, dividerX, unopposedBarPulse: shouldPulseUnopposedBars, + mySeat, }); const { handleHalftimeBan, handleHalftimeBanPhaseShown } = useHalftimeBanController({ @@ -506,10 +530,12 @@ export function useRealtimePossessionMatchController({ props: { penaltyPlayerScore: fieldState.myPenaltyGoals, penaltyOpponentScore: fieldState.oppPenaltyGoals, + penaltyPlayerAttempts: fieldState.myPenaltyAttempts, + penaltyOpponentAttempts: fieldState.oppPenaltyAttempts, playerPoints, opponentPoints, - penaltyRound: Math.max(1, possessionState.phaseRound), - isPenaltySuddenDeath: possessionState.penaltySuddenDeath ?? false, + penaltyRound, + isPenaltySuddenDeath, isPlayerShooter: fieldState.shooterIsMe, playerName: playerUsername, opponentName: opponentUsername, @@ -546,6 +572,7 @@ export function useRealtimePossessionMatchController({ opponentAnswered: state.opponentAnswered, opponentAnsweredCorrectly, speedStreakMine: fieldState.speedStreakMine, + speedStreakOpponent: fieldState.speedStreakOpponent, }, }, pitchProps: { @@ -584,6 +611,9 @@ export function useRealtimePossessionMatchController({ isPenaltyPhase: fieldState.isPenaltyQuestion, isShotPhase: fieldState.isShotVisualPhase, isLastAttackPhase: fieldState.isLastAttackQuestion, + penaltyDisplayRound, + penaltyDisplayTotal, + isPenaltySuddenDeath, question, qIndex: localQuestion?.qIndex ?? 0, totalQuestions: localQuestion?.total ?? 12, @@ -613,6 +643,10 @@ export function useRealtimePossessionMatchController({ matchId: possessionMatch.matchId, qIndex: localQuestion.qIndex, totalQuestions: localQuestion.total ?? 12, + isPenaltyPhase: fieldState.isPenaltyQuestion, + penaltyDisplayRound, + penaltyDisplayTotal, + isPenaltySuddenDeath, question: specialQuestion, showOptions: state.showOptions, timeRemaining: state.timeRemaining, @@ -645,6 +679,8 @@ export function useRealtimePossessionMatchController({ playerAvatarCustomization, opponentAvatarCustomization, playerPosition: fieldState.visualMyPossessionPct, + playerRankPoints, + opponentRankPoints, playerCountryCode, opponentCountryCode, deadlineAt: possessionState.halftime.deadlineAt, @@ -664,6 +700,8 @@ export function useRealtimePossessionMatchController({ : null, onBanCategory: handleHalftimeBan, onBanPhaseShown: handleHalftimeBanPhaseShown, + // Pre-penalty ban reuses the halftime ban UI but with a penalty title. + isPenaltyBan: possessionState.halftime.purpose === 'penalty', }; const handleTemporaryQuit = useCallback(() => { diff --git a/src/features/profile/ProfileWeb.tsx b/src/features/profile/ProfileWeb.tsx index b287c25c..c65181ca 100644 --- a/src/features/profile/ProfileWeb.tsx +++ b/src/features/profile/ProfileWeb.tsx @@ -808,7 +808,11 @@ export function ProfileWeb({ : match.competition === 'placement' ? t('profileScreen.modePlacement') : t('profileScreen.modeRanked'); - const showRpDelta = match.competition !== 'friendly' && match.rpDelta !== null; + const isPlacementMatch = match.competition === 'placement'; + // Placement matches don't move RP per win/loss (the seed is + // applied once), so a +/- RP number is misleading — show a + // neutral "Placement" badge instead. Ranked still shows RP. + const showRpDelta = !isPlacementMatch && match.competition !== 'friendly' && match.rpDelta !== null; const rpDelta = match.rpDelta ?? 0; const formattedRpDelta = `${rpDelta >= 0 ? '+' : ''}${rpDelta} RP`; return ( @@ -839,6 +843,11 @@ export function ProfileWeb({ {/* RP + Score */}
+ {isPlacementMatch && ( + + {t('recentMatches.placementMatch')} + + )} {showRpDelta && ( {formattedRpDelta} diff --git a/src/features/profile/components/AvatarPicker.tsx b/src/features/profile/components/AvatarPicker.tsx index 4c2def70..396058e1 100644 --- a/src/features/profile/components/AvatarPicker.tsx +++ b/src/features/profile/components/AvatarPicker.tsx @@ -4,9 +4,9 @@ import Image from "next/image"; import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { ModalCloseButton } from "@/components/shared/ModalCloseButton"; import { cn } from "@/lib/utils"; import { useIsMobile } from "@/hooks/useMobile"; import { @@ -30,6 +30,7 @@ import { purchaseStoreWithCoins } from "@/lib/repositories/store.repo"; import { queryKeys } from "@/lib/queries/queryKeys"; import { ApiError } from "@/lib/api/api"; import { useLocale } from "@/contexts/LocaleContext"; +import { translatePartName } from "@/lib/avatars/partNames"; import type { AvatarCustomization } from "@/types/game"; type SlotTab = "skin" | "jersey" | "hair" | "glasses" | "facialHair"; @@ -72,12 +73,25 @@ export function AvatarPicker({ part?: AvatarPart; } | null>(null); - /** Current full customization. Missing customization intentionally renders the default avatar. */ + /** The committed customization (from props). Missing → default avatar. */ const current = useMemo( () => currentCustomization ?? customizationFromAvatarValue(null), [currentCustomization], ); + // Working "draft" look — selecting items only PREVIEWS them here; nothing is + // saved until the user taps Save. Lets the user try multiple parts together. + // Re-seeded from the committed look on each OPEN transition (so reopening + // discards an unsaved draft). Uses the React "adjust state during render" + // pattern (compare a tracked value, no setState-in-effect). + const [draft, setDraft] = useState(current); + const [wasOpen, setWasOpen] = useState(open); + if (open !== wasOpen) { + setWasOpen(open); + if (open) setDraft(current); // re-seed on the closed→open transition + } + const isDirty = encodeAvatarCustomization(draft) !== encodeAvatarCustomization(current); + /** Set of part ids the user owns (free + purchased). In dev mode, everything is unlocked * so designers/devs can preview all items in the picker. */ const ownedPartIds = useMemo(() => { @@ -102,8 +116,9 @@ export function AvatarPicker({ return set; }, [inventoryData]); - const persist = (next: AvatarCustomization) => { - onSelect(encodeAvatarCustomization(next)); + // Commit the draft look (called by the Save button). + const handleSave = () => { + onSelect(encodeAvatarCustomization(draft)); }; const purchaseMutation = useMutation({ @@ -112,12 +127,14 @@ export function AvatarPicker({ void queryClient.invalidateQueries({ queryKey: queryKeys.store.inventory() }); void queryClient.invalidateQueries({ queryKey: queryKeys.store.wallet() }); const p = pendingPurchase; + // Apply the freshly-bought item to the DRAFT (preview). It's saved when the + // user taps Save, like any other selection. if (p?.type === "skin" && p.skin) { - persist({ ...current, skin: p.skin.id }); - toast.success(t('profile.skinEquipped', { name: p.skin.name })); + setDraft((d) => ({ ...d, skin: p.skin!.id })); + toast.success(t('profile.skinEquipped', { name: translatePartName(p.skin.name, t) })); } else if (p?.type === "part" && p.part) { - persist({ ...current, [p.part.slot]: p.part.id }); - toast.success(t('profile.partEquipped', { name: p.part.name })); + setDraft((d) => ({ ...d, [p.part!.slot]: p.part!.id })); + toast.success(t('profile.partEquipped', { name: translatePartName(p.part.name, t) })); } setPendingPurchase(null); }, @@ -131,8 +148,9 @@ export function AvatarPicker({ }); const handleSelectSkin = (skin: SkinPart) => { + // Owned/free → preview in the draft (saved on Save). Locked → buy first. if (skin.free || ownedPartIds.has(skin.id)) { - persist({ ...current, skin: skin.id }); + setDraft((d) => ({ ...d, skin: skin.id })); return; } if (skin.productSlug) { @@ -145,13 +163,15 @@ export function AvatarPicker({ part: AvatarPart | null, ) => { if (part === null) { - const next = { ...current }; - delete next[slot]; - persist(next); + setDraft((d) => { + const next = { ...d }; + delete next[slot]; + return next; + }); return; } if (part.free || ownedPartIds.has(part.id)) { - persist({ ...current, [slot]: part.id }); + setDraft((d) => ({ ...d, [slot]: part.id })); return; } if (part.productSlug) { @@ -189,18 +209,17 @@ export function AvatarPicker({
{SKIN_PARTS.map((skin) => { const owned = ownedPartIds.has(skin.id); - const selected = current.skin === skin.id; - const previewCustomization: AvatarCustomization = { ...current, skin: skin.id }; + const selected = draft.skin === skin.id; + const previewCustomization: AvatarCustomization = { ...draft, skin: skin.id }; return ( -
- - {/* In-picker buy confirmation for locked items */} + {/* In-picker buy confirmation for locked items — brand-styled like the + mode-confirm modal: red square close, green confirm, red-outline cancel. */} {pendingPurchase && ( setPendingPurchase(null)}> - + + setPendingPurchase(null)} /> - + {t('profile.purchase.confirmTitle')} - + {pendingPurchase.type === "skin" - ? t('profile.purchase.unlockSkin', { name: pendingPurchase.skin?.name ?? '' }) - : t('profile.purchase.unlockPart', { name: pendingPurchase.part?.name ?? '' })} + ? t('profile.purchase.unlockSkin', { name: translatePartName(pendingPurchase.skin?.name ?? '', t) }) + : t('profile.purchase.unlockPart', { name: translatePartName(pendingPurchase.part?.name ?? '', t) })}
@@ -383,18 +397,17 @@ export function AvatarPicker({ {t('profile.purchase.coinsLabel')}
-
- - +
)} + + {/* Save — commits the whole previewed look at once. Sticks to the bottom so + it's reachable after trying several parts. Disabled until something changed. */} +
+ +
); @@ -426,10 +460,11 @@ export function AvatarPicker({ + onOpenChange(false)} /> - {t('profile.avatarPicker.title')} + {t('profile.avatarPicker.title')} {Content} @@ -439,10 +474,11 @@ export function AvatarPicker({ return ( - + + onOpenChange(false)} /> - {t('profile.avatarPicker.title')} - + {t('profile.avatarPicker.title')} + {t('profile.avatarPicker.description')} diff --git a/src/features/settings/SettingsScreen.tsx b/src/features/settings/SettingsScreen.tsx index 0accd046..08e028ee 100644 --- a/src/features/settings/SettingsScreen.tsx +++ b/src/features/settings/SettingsScreen.tsx @@ -6,13 +6,13 @@ import { Input } from "@/components/ui/input"; import { AlertDialog, AlertDialogAction, - AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { ModalCloseButton } from "@/components/shared/ModalCloseButton"; import { Dialog, DialogContent, @@ -93,6 +93,13 @@ export function SettingsScreen({ onBack }: SettingsScreenProps) { const normalizedDeleteConfirmation = deleteConfirmation.trim().toLocaleUpperCase("en-US"); const normalizedDeletionConfirmWord = deletionConfirmWord.trim().toLocaleUpperCase("en-US"); const canConfirmDeletion = normalizedDeleteConfirmation === normalizedDeletionConfirmWord && !isDeletingAccount; + // Dismiss the delete-account dialog (the top-right X — replaces the old + // bottom "Cancel" button so this modal matches the app-wide close pattern). + const closeDeleteDialog = () => { + if (isDeletingAccount) return; + setDeleteDialogOpen(false); + setDeleteConfirmation(""); + }; const canUseDevReset = user?.role === "admin"; const currentPhone = user?.phone_number ?? null; const canUseGeorgianPhoneAuth = phoneAuthAvailability.isAvailable; @@ -193,6 +200,7 @@ export function SettingsScreen({ onBack }: SettingsScreenProps) { } setPhoneStep("otp"); setPhoneNotice(t("settings.phoneCodeSent", { phone: result.phone })); + toast.success(t("settings.phoneCodeSent", { phone: result.phone })); return; } @@ -277,7 +285,7 @@ export function SettingsScreen({ onBack }: SettingsScreenProps) { }; return ( -
+
{/* Nav */}
- diff --git a/src/features/welcome/WelcomeAuthNoticeModal.tsx b/src/features/welcome/WelcomeAuthNoticeModal.tsx new file mode 100644 index 00000000..5f8a1f86 --- /dev/null +++ b/src/features/welcome/WelcomeAuthNoticeModal.tsx @@ -0,0 +1,109 @@ +'use client'; + +/** + * Centered confirmation modal shown after an email sign-up attempt. + * + * Two variants: + * - "check-email" — a new sign-up; tells the user to confirm via email. + * - "already-registered" — the email already has an account; nudges to sign in. + * + * Replaces the easy-to-miss inline notice at the bottom of the auth dialog so + * the message is unmissable. + */ + +import { CheckCircle2, MailCheck, RotateCcw } from 'lucide-react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useLocale } from '@/contexts/LocaleContext'; + +export type AuthNoticeVariant = 'check-email' | 'already-registered' | 'pending-deletion'; + +interface WelcomeAuthNoticeModalProps { + open: boolean; + variant: AuthNoticeVariant; + onClose: () => void; + /** Shown only for the already-registered variant: takes the user to sign-in. */ + onGoToSignIn: () => void; + onRestorePendingDeletion?: () => void; + restoreSubmitting?: boolean; + restoreError?: string | null; +} + +export function WelcomeAuthNoticeModal({ + open, + variant, + onClose, + onGoToSignIn, + onRestorePendingDeletion, + restoreSubmitting = false, + restoreError = null, +}: WelcomeAuthNoticeModalProps) { + const { t } = useLocale(); + + const isAlreadyRegistered = variant === 'already-registered'; + const isPendingDeletion = variant === 'pending-deletion'; + const title = isAlreadyRegistered + ? t('welcome.alreadyRegisteredTitle') + : isPendingDeletion + ? t('welcome.restoreAccountTitle') + : t('welcome.checkEmailTitle'); + const body = isAlreadyRegistered + ? t('welcome.alreadyRegistered') + : isPendingDeletion + ? t('welcome.restoreAccountDescription') + : t('welcome.checkEmail'); + const Icon = isAlreadyRegistered ? CheckCircle2 : isPendingDeletion ? RotateCcw : MailCheck; + + return ( + { if (!next) onClose(); }}> + + +
+
+ + {title} + + + {body} + +
+ +
+ {restoreError ? ( +

+ {restoreError} +

+ ) : null} + {isPendingDeletion ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/features/welcome/WelcomeFacebookButton.tsx b/src/features/welcome/WelcomeFacebookButton.tsx index 82d71314..ed5e8ace 100644 --- a/src/features/welcome/WelcomeFacebookButton.tsx +++ b/src/features/welcome/WelcomeFacebookButton.tsx @@ -6,24 +6,33 @@ */ import { FaFacebookF } from 'react-icons/fa'; +import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useLocale } from '@/contexts/LocaleContext'; interface WelcomeFacebookButtonProps { onClick: () => void; + submitting?: boolean; } -export function WelcomeFacebookButton({ onClick }: WelcomeFacebookButtonProps) { +export function WelcomeFacebookButton({ onClick, submitting = false }: WelcomeFacebookButtonProps) { const { t } = useLocale(); return ( ); } diff --git a/src/features/welcome/WelcomeGoogleButton.tsx b/src/features/welcome/WelcomeGoogleButton.tsx index 00bfd5cd..3b55a280 100644 --- a/src/features/welcome/WelcomeGoogleButton.tsx +++ b/src/features/welcome/WelcomeGoogleButton.tsx @@ -1,31 +1,150 @@ 'use client'; /** - * "Continue with Google" CTA inside the login dialog. The handler - * (which owns the GIS / in-app-browser / redirect-fallback flow) - * lives in useWelcomeAuthController; this component is presentation - * only. + * "Continue with Google" CTA inside the login dialog. + * + * GIS-everywhere strategy: visually this is our branded yellow button, but + * Google's own GIS button is rendered transparently on top, stretched to cover + * the ENTIRE button so a tap anywhere goes through GIS (deterministic — no + * center/edge dead zones). The rendered button runs a Google-hosted popup that + * returns an id_token, which works in normal browsers and some embedded + * webviews like Instagram — the token endpoint isn't subject to the + * `disallowed_useragent` block that kills the classic OAuth redirect. The + * credential is exchanged for a session via socialLoginWithIdToken. + * + * Redirect is the FALLBACK only: if GIS can't load (rare locked-down webview), + * the overlay never renders, `gisReady` stays false, and the visible button's + * onClick runs the classic redirect flow instead. + * + * In Messenger/Facebook-style in-app browsers the overlay is disabled so the + * visible React button can run the external-browser bounce instead of being + * intercepted by Google's iframe. */ +import { useCallback, useEffect, useRef, useState } from 'react'; import { FcGoogle } from 'react-icons/fc'; +import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useLocale } from '@/contexts/LocaleContext'; +import { renderGoogleButton, type GoogleCredential } from '@/lib/auth/google-identity'; interface WelcomeGoogleButtonProps { + clientId: string; onClick: () => void; + onCredential: (credential: GoogleCredential) => void; + submitting?: boolean; + disableIdentityOverlay?: boolean; } -export function WelcomeGoogleButton({ onClick }: WelcomeGoogleButtonProps) { +function hasRenderedGoogleButton(container: HTMLElement): boolean { + return Boolean(container.firstElementChild || container.querySelector('iframe')); +} + +export function WelcomeGoogleButton({ + clientId, + onClick, + onCredential, + submitting = false, + disableIdentityOverlay = false, +}: WelcomeGoogleButtonProps) { const { t } = useLocale(); + const overlayRef = useRef(null); + const [width, setWidth] = useState(0); + // True once Google's real (overlaid) button has rendered. When ready, ALL + // clicks go through GIS (the overlay covers the whole button); the visible + // button's onClick redirect-fallback only fires when GIS never rendered. + const [gisReady, setGisReady] = useState(false); + const onCredentialRef = useRef(onCredential); + const observerRef = useRef(null); + useEffect(() => { + onCredentialRef.current = onCredential; + }, [onCredential]); + + // Measure the visible button so Google's iframe is rendered at the same + // width — callback ref, not useEffect+ref, so it survives the dialog's + // conditional DOM (see project ResizeObserver guidance). Falls back to the + // configured max-w-md dialog width when measurement isn't available (e.g. + // jsdom), so the overlay still renders. Callback refs ignore returned + // cleanups, so the observer is tracked in a ref and disconnected on the + // next ref call and on unmount. + const measureRef = useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + if (!node) return; + const update = () => { + const measured = Math.round(node.getBoundingClientRect().width); + setWidth(measured > 0 ? measured : 320); + }; + update(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(update); + observer.observe(node); + observerRef.current = observer; + }, []); + + useEffect(() => () => observerRef.current?.disconnect(), []); + + useEffect(() => { + const container = overlayRef.current; + if (!container || !clientId || width <= 0 || disableIdentityOverlay) { + container?.replaceChildren(); + return; + } + let cancelled = false; + void renderGoogleButton(clientId, container, width, (credential) => { + if (!cancelled) onCredentialRef.current(credential); + }).then((rendered) => { + if (!cancelled) setGisReady(rendered && hasRenderedGoogleButton(container)); + }).catch(() => { + if (!cancelled) setGisReady(false); + }); + return () => { + cancelled = true; + }; + }, [clientId, disableIdentityOverlay, width]); + + const overlayInteractive = Boolean(clientId && width > 0 && !disableIdentityOverlay && gisReady); + + // GIS-everywhere with redirect fallback: when a real GIS iframe/button exists, + // the overlay owns every click. Otherwise the visible button falls back to the + // prompt/redirect path instead of becoming a dead CTA. + const handleVisibleClick = useCallback(() => { + const container = overlayRef.current; + if (overlayInteractive && container && hasRenderedGoogleButton(container)) return; // overlay handles it + onClick(); + }, [onClick, overlayInteractive]); + return ( - +
+ + + {/* Real GIS button, rendered transparently on top. It captures the tap + and runs Google's popup token flow. Near-zero opacity keeps our button + visible underneath. The rendered Google iframe is stretched to fill the + WHOLE button (scale-y) so a tap ANYWHERE goes through GIS — no + center/edge dead zones, so behaviour is deterministic. While submitting, + it's click-blocked so it can't be re-fired mid-sign-in. */} +
*]:h-full [&>*]:w-full [&_iframe]:h-full [&_iframe]:w-full ${ + submitting || !overlayInteractive ? 'pointer-events-none [&>*]:pointer-events-none' : 'pointer-events-none [&>*]:pointer-events-auto' + }`} + /> +
); } diff --git a/src/features/welcome/WelcomeLoginDialog.tsx b/src/features/welcome/WelcomeLoginDialog.tsx index 2985adbc..828285e7 100644 --- a/src/features/welcome/WelcomeLoginDialog.tsx +++ b/src/features/welcome/WelcomeLoginDialog.tsx @@ -1,10 +1,12 @@ 'use client'; +import { useState } from 'react'; import { ChevronDown } from 'lucide-react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { ModalCloseButton } from '@/components/shared/ModalCloseButton'; import { useLocale } from '@/contexts/LocaleContext'; -import { getPlatform, tryOpenInExternalBrowser } from '@/lib/auth/in-app-browser'; +import type { GoogleCredential } from '@/lib/auth/google-identity'; +import { getPlatform } from '@/lib/auth/in-app-browser'; import { InAppBrowserInstructions } from './InAppBrowserInstructions'; import { WelcomeGoogleButton } from './WelcomeGoogleButton'; import { WelcomeFacebookButton } from './WelcomeFacebookButton'; @@ -17,6 +19,8 @@ import type { AuthFieldErrors } from '@/lib/auth/validation'; interface WelcomeLoginDialogProps { open: boolean; showOpenInBrowser: boolean; + googleClientId: string; + disableGoogleIdentityOverlay: boolean; authMode: AuthPanelMode; authEmail: string; authPassword: string; @@ -28,6 +32,7 @@ interface WelcomeLoginDialogProps { authError: string | null; authFieldErrors: AuthFieldErrors; phoneOtpSent: boolean; + socialSubmitting: 'google' | 'facebook' | null; showAdvancedAuth: boolean; showForgot: boolean; forgotSubmitting: boolean; @@ -38,6 +43,7 @@ interface WelcomeLoginDialogProps { onOpenChange: (open: boolean) => void; onClose: () => void; onGoogleLogin: () => void; + onGoogleCredential: (credential: GoogleCredential) => void; onFacebookLogin: () => void; onAuthModeChange: (mode: AuthPanelMode) => void; onEmailChange: (value: string) => void; @@ -55,6 +61,8 @@ interface WelcomeLoginDialogProps { export function WelcomeLoginDialog({ open, showOpenInBrowser, + googleClientId, + disableGoogleIdentityOverlay, authMode, authEmail, authPassword, @@ -66,6 +74,7 @@ export function WelcomeLoginDialog({ authError, authFieldErrors, phoneOtpSent, + socialSubmitting, showAdvancedAuth, showForgot, forgotSubmitting, @@ -76,6 +85,7 @@ export function WelcomeLoginDialog({ onOpenChange, onClose, onGoogleLogin, + onGoogleCredential, onFacebookLogin, onAuthModeChange, onEmailChange, @@ -90,11 +100,24 @@ export function WelcomeLoginDialog({ onForgotSubmit, }: WelcomeLoginDialogProps) { const { t } = useLocale(); - const authModes: AuthPanelMode[] = showPhoneAuth - ? ['signin', 'signup', 'phone'] - : ['signin', 'signup']; + // Phone is no longer its own tab — it lives under Sign In as an Email|Phone + // method toggle (only one form shows at a time, keeping the modal short). + // Two tabs remain; coerce any legacy 'phone' mode to 'signin'. + const authModes: AuthPanelMode[] = ['signin', 'signup']; const effectiveAuthMode: AuthPanelMode = - showPhoneAuth || authMode !== 'phone' ? authMode : 'signin'; + authMode === 'phone' ? 'signin' : authMode; + + // Which sign-in method is shown. Force Phone while an OTP is in flight so the + // verify step isn't hidden behind the Email view. + const [signinMethod, setSigninMethod] = useState<'email' | 'phone'>('email'); + const activeSigninMethod: 'email' | 'phone' = phoneOtpSent ? 'phone' : signinMethod; + const phoneFormActive = + showPhoneAuth && effectiveAuthMode === 'signin' && activeSigninMethod === 'phone'; + // Once the OTP code has been sent, collapse the social buttons, options + // disclosure, tabs, and method toggle so the modal becomes a focused + // "enter your code" step. The phone form itself shows a "change number" + // affordance, so the user can still correct a typo without this chrome. + const inOtpStep = phoneFormActive && phoneOtpSent; return ( @@ -102,10 +125,7 @@ export function WelcomeLoginDialog({ {showOpenInBrowser ? ( - tryOpenInExternalBrowser(window.location.href)} - /> + ) : showForgot ? ( <> @@ -133,64 +153,98 @@ export function WelcomeLoginDialog({ {t('welcome.loginDescription')} -
- - -
+ {!inOtpStep ? ( + <> +
+ + +
-
-
- -
-
+
+
+ +
+
+ + ) : null} {showAdvancedAuth ? (
-
- {authModes.map((mode) => ( - - ))} -
+ {!inOtpStep ? ( +
+ {authModes.map((mode) => ( + + ))} +
+ ) : null} + + {/* Sign In: small, secondary Email | Phone method toggle (swaps + the form below so the modal stays short). Deliberately smaller + than the Sign In/Sign Up tabs. Phone has no toggle on Sign Up. + Hidden during the OTP step to keep the modal focused. */} + {!inOtpStep && showPhoneAuth && effectiveAuthMode === 'signin' ? ( +
+
+ {(['email', 'phone'] as const).map((method) => ( + + ))} +
+
+ ) : null} - {effectiveAuthMode === 'phone' ? ( + {phoneFormActive ? ( ) : null} - {authNotice ? ( + {/* During the phone OTP step the form shows its own "code sent" hint, + so suppress the generic notice banner to avoid duplicate messaging. */} + {authNotice && !inOtpStep ? (

{authNotice}

) : null} - {authError ? ( + {/* The phone form renders its own inline error; avoid duplicating it + in the big banner below. */} + {authError && !phoneFormActive ? (

{authError}

diff --git a/src/features/welcome/WelcomePhoneAuthForm.tsx b/src/features/welcome/WelcomePhoneAuthForm.tsx index 18d61eef..85bfcd7f 100644 --- a/src/features/welcome/WelcomePhoneAuthForm.tsx +++ b/src/features/welcome/WelcomePhoneAuthForm.tsx @@ -1,69 +1,147 @@ 'use client'; -import { Loader2, Phone } from 'lucide-react'; +import { Check, Loader2, Phone } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useLocale } from '@/contexts/LocaleContext'; +const GE_PREFIX = '+995'; +const LOCAL_DIGITS = 9; + interface WelcomePhoneAuthFormProps { + /** Full number, e.g. "+995598373017". */ phone: string; otp: string; otpSent: boolean; submitting: boolean; + error?: string | null; onPhoneChange: (value: string) => void; onOtpChange: (value: string) => void; onSubmit: (event: React.FormEvent) => void; } +/** Format the stored full number for display, e.g. "+995 598 370 017". */ +function formatDisplayNumber(phone: string): string { + const digits = phone.replace(/\D/g, ''); + const local = (digits.startsWith('995') ? digits.slice(3) : digits).slice(0, LOCAL_DIGITS); + const grouped = local.replace(/(\d{3})(\d{3})(\d{0,3})/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' ')); + return `${GE_PREFIX} ${grouped}`.trim(); +} + +/** Strip everything but the 9 local digits from a stored full/partial number. */ +function toLocalDigits(phone: string): string { + const digits = phone.replace(/\D/g, ''); + const withoutCountry = digits.startsWith('995') ? digits.slice(3) : digits; + return withoutCountry.slice(0, LOCAL_DIGITS); +} + export function WelcomePhoneAuthForm({ phone, otp, otpSent, submitting, + error = null, onPhoneChange, onOtpChange, onSubmit, }: WelcomePhoneAuthFormProps) { const { t } = useLocale(); + const localDigits = toLocalDigits(phone); + + const handleLocalChange = (raw: string) => { + // Tolerate a pasted full number (+995…, 995…) by stripping a leading country + // code before keeping the 9 local digits — so paste never doubles the prefix. + const digits = raw.replace(/\D/g, ''); + const local = (digits.startsWith('995') ? digits.slice(3) : digits).slice(0, LOCAL_DIGITS); + // Always emit the full number so the rest of the flow is unchanged. + onPhoneChange(local ? `${GE_PREFIX}${local}` : ''); + }; + return (
-