feat: UNO-style table, social hub, cosmetics, speed mode, store IAB

Game table & play
- UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow,
  big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round
  confetti, match coin-rain.
- Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert;
  mirrored server-side in GameRoom.TurnMs.
- Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing.
- Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint.

Rewards / gifts
- Richer post-match modal (floating coins, XP bar), celebration overlay reveals
  the unlocked sticker pack, boosted daily rewards (client+server synced),
  themed 7-day daily with special day-7.

Social
- Public profile modal (identity, stats, achievement board) from leaderboard /
  friends / discover / end-of-game roster; rate-limited add-friend (10/hour).
- Social hub: Friends / Discover (player search + suggestions) / Messages inbox.
- Profile gender (shown in finder/profile) + social links with public/friends/
  hidden visibility, enforced server-side.

Cosmetics
- Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/
  rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts),
  consistent on table/shop/profile; +Peacock/Rose-Gold backs.
- Purchasable titles (shop Titles section); title shown under the seat on the
  table and in discover/public profile.
- 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods).
- Persistent level+XP bar on Home and every inner screen.

Payments
- Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh.
- Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture,
  Myket native-bridge contract, server-side IabService.Verify for both stores,
  config-driven via Iab__* env. POST /api/coins/iab/verify (JWT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 18:39:24 +03:30
parent e450a6a2ed
commit cb27a16dc1
49 changed files with 3438 additions and 592 deletions
+318 -104
View File
@@ -1,9 +1,9 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { TURN_MS, useGameStore } from "@/lib/game-store";
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff, Zap } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useGameStore } from "@/lib/game-store";
import { useSoundStore } from "@/lib/sound-store";
import { legalMoves } from "@/lib/hokm/engine";
import { sortHand } from "@/lib/hokm/deck";
@@ -18,7 +18,7 @@ import {
} from "@/lib/hokm/types";
import { useI18n } from "@/lib/i18n";
import { useSessionStore } from "@/lib/session-store";
import { cardBackById, cardFrontById, ownedReactions, ownedStickers } from "@/lib/online/gamification";
import { cardBackById, cardFrontById, ownedReactions, ownedStickers, titleById, turnMsForStake } from "@/lib/online/gamification";
import { getService } from "@/lib/online/service";
import { cn } from "@/lib/cn";
import { PlayingCard } from "./PlayingCard";
@@ -43,7 +43,7 @@ function useCardSkins() {
const b = cardBackById(backId);
return {
front: { bg1: f.bg1, bg2: f.bg2, border: f.border },
back: { c1: b.c1, c2: b.c2, accent: b.accent },
back: { c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif },
};
}
@@ -68,11 +68,48 @@ export function GameTable({
const trickScale = vw < 360 ? 0.5 : vw < 460 ? 0.64 : 1;
const { phase, players, hakem, trump, turn, currentTrick } = game;
const legalIds = new Set(
phase === "playing" && turn === 0
? legalMoves(game, 0).map((c) => c.id)
: []
const legalMovesList = useMemo(
() => (phase === "playing" && turn === 0 ? legalMoves(game, 0) : []),
[phase, turn, game]
);
const legalIds = new Set(legalMovesList.map((c) => c.id));
// Keyboard shortcuts (desktop): 19 / 0 play the Nth playable card in hand
// order, Space/Enter play the first playable card, M mutes, F forfeits,
// Esc/Q quits. A floating hint lists them.
const playHuman = useGameStore((s) => s.playHuman);
const chooseTrump = useGameStore((s) => s.chooseTrump);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
const k = e.key.toLowerCase();
// Hakem choosing trump: 14 pick a suit.
if (phase === "choosing-trump" && players[hakem!]?.isHuman) {
const idx = "1234".indexOf(e.key);
if (idx >= 0) { e.preventDefault(); chooseTrump(SUITS[idx]); return; }
}
if (phase === "playing" && turn === 0) {
const playable = sortHand(game.players[0].hand).filter((c) => legalIds.has(c.id));
if (k === " " || k === "enter") {
if (playable[0]) { e.preventDefault(); playHuman(playable[0]); }
return;
}
// 1-9 then 0 → 10th
const digit = e.key === "0" ? 9 : "123456789".indexOf(e.key);
if (digit >= 0 && playable[digit]) { e.preventDefault(); playHuman(playable[digit]); return; }
}
if (k === "m") { e.preventDefault(); toggleAll(); }
else if (k === "f" && onForfeit) { e.preventDefault(); setAskFf(true); }
else if (k === "escape" || k === "q") { e.preventDefault(); exit(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, turn, hakem, game.players, legalMovesList]);
return (
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
@@ -80,6 +117,7 @@ export function GameTable({
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between gap-2 safe-top safe-x pb-3 sm:p-4">
<Scoreboard />
<div className="flex items-center gap-2">
<SpeedBadge />
{trump && <TrumpBadge trump={trump} />}
<button
onClick={toggleAll}
@@ -170,6 +208,7 @@ export function GameTable({
<TurnTimer />
<DisconnectBanner />
<Reactions />
<ShortcutsHint />
{/* Overlays */}
<AnimatePresence>
@@ -237,6 +276,25 @@ function ScoreCol({
);
}
/* ----------------------------- Speed badge ---------------------------- */
function SpeedBadge() {
const speed = useGameStore((s) => s.matchMeta.speed);
const { t } = useI18n();
if (!speed) return null;
return (
<motion.div
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
className="glass rounded-2xl px-2.5 py-2 flex items-center gap-1 text-gold-300"
title={t("speed.label")}
>
<Zap className="size-4 fill-gold-400 text-gold-400" />
<span className="text-[10px] font-black uppercase tracking-wide">{t("speed.label")}</span>
</motion.div>
);
}
/* ----------------------------- Trump badge ---------------------------- */
function TrumpBadge({ trump }: { trump: Suit }) {
@@ -268,6 +326,7 @@ function TrumpBadge({ trump }: { trump: Suit }) {
function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
const game = useGameStore((s) => s.game);
const sp = useGameStore((s) => s.seatPlayers[seat]);
const { locale } = useI18n();
const player = game.players[seat];
const active =
(game.phase === "playing" && game.turn === seat) ||
@@ -275,30 +334,33 @@ function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
const isHakem = game.hakem === seat;
const team = teamOf(seat);
const name = sp?.name ?? player.name;
const titleDef = titleById(sp?.title);
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
return (
<div className={cn("z-20 flex flex-col items-center gap-1", className)}>
<motion.div
animate={
active
? { boxShadow: "0 0 0 3px rgba(212,175,55,0.9), 0 0 24px rgba(212,175,55,0.5)" }
: { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" }
}
<div
className={cn(
"relative size-10 sm:size-12 rounded-full flex items-center justify-center font-bold text-lg sm:text-xl",
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100"
"relative size-12 sm:size-14 rounded-full flex items-center justify-center font-bold text-xl sm:text-2xl transition-all",
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100",
active && "active-player-ring"
)}
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
>
{sp?.avatar ?? name.charAt(0)}
{isHakem && (
<Crown className="absolute -top-3 size-4 text-gold-400 fill-gold-500" />
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
)}
</motion.div>
{active && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 size-2.5 rounded-full bg-gold-400 ring-2 ring-navy-900" />
)}
</div>
<span className="text-[11px] font-semibold text-cream max-w-20 truncate hud-shadow">{name}</span>
{titleName && (
<span className="text-[9px] font-bold gold-text leading-none max-w-24 truncate hud-shadow">{titleName}</span>
)}
{sp && sp.level > 0 && (
<span className="text-[9px] text-gold-300 leading-none hud-shadow">
{`Lv ${sp.level}`}
</span>
<span className="text-[10px] text-gold-300/80 leading-none hud-shadow">{`Lv ${sp.level}`}</span>
)}
</div>
);
@@ -318,22 +380,26 @@ function OpponentHand({
const count = useGameStore((s) => s.game.players[seat].hand.length);
const { back } = useCardSkins();
const cards = Array.from({ length: count });
const mid = (count - 1) / 2;
return (
<div
className={cn(
"flex",
horizontal ? "flex-row" : "flex-col",
className
)}
>
{cards.map((_, i) => (
<div
key={i}
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
>
<PlayingCard faceDown size="sm" back={back} />
</div>
))}
<div className={cn("flex", horizontal ? "flex-row items-end" : "flex-col items-center", className)}>
{cards.map((_, i) => {
const rot = horizontal ? (i - mid) * 4 : 0;
return (
<div
key={i}
style={{
...(horizontal
? { marginInlineStart: i === 0 ? 0 : -30 }
: { marginTop: i === 0 ? 0 : -46 }),
transform: rot ? `rotate(${rot}deg)` : undefined,
transformOrigin: "bottom center",
}}
>
<PlayingCard faceDown size="sm" back={back} />
</div>
);
})}
</div>
);
}
@@ -367,29 +433,23 @@ function TrickArea({
const { front } = useCardSkins();
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-1 ">
<div className="relative size-1">
<AnimatePresence>
{trick.map((pc) => {
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
const enter = TRICK_ENTER[pc.seat];
const isWinner =
phase === "trick-complete" && winner === pc.seat;
const isWinner = phase === "trick-complete" && winner === pc.seat;
return (
<motion.div
key={pc.card.id}
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
animate={{
x: off.x,
y: off.y,
opacity: 1,
scale: isWinner ? 1.12 : 1,
}}
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 260, damping: 26 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{
filter: isWinner
? "drop-shadow(0 0 14px rgba(212,175,55,0.9))"
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
: undefined,
}}
>
@@ -398,11 +458,55 @@ function TrickArea({
);
})}
</AnimatePresence>
{/* Burst particles when trick is won */}
<AnimatePresence>
{phase === "trick-complete" && winner != null && (
<TrickBurst key={`burst-${winner}`} seat={winner} />
)}
</AnimatePresence>
</div>
</div>
);
}
/* particles that fly out from center when you win a trick */
const BURST_ANGLES = Array.from({ length: 10 }, (_, i) => {
const a = (i / 10) * 2 * Math.PI;
const d = 55 + (i % 3) * 22;
return { id: i, x: Math.cos(a) * d, y: Math.sin(a) * d, size: 7 + (i % 3) * 5 };
});
function TrickBurst({ seat }: { seat: Seat }) {
const team = teamOf(seat);
const gradient = team === 0
? "radial-gradient(circle,#2dd4bf,#0d9488)"
: "radial-gradient(circle,#fb7185,#e11d48)";
const glowColor = team === 0 ? "rgba(45,212,191,0.55)" : "rgba(251,113,133,0.55)";
return (
<>
{/* centre flash */}
<motion.div
initial={{ scale: 0, opacity: 0.85 }}
animate={{ scale: 3.5, opacity: 0 }}
transition={{ duration: 0.38 }}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
style={{ width: 44, height: 44, background: glowColor }}
/>
{BURST_ANGLES.map(p => (
<motion.div
key={p.id}
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
animate={{ x: p.x, y: p.y, opacity: 0, scale: 0 }}
transition={{ duration: 0.55, ease: [0.2, 0.8, 0.4, 1] }}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
style={{ width: p.size, height: p.size, background: gradient }}
/>
))}
</>
);
}
/* ----------------------------- Player hand ---------------------------- */
function useViewportWidth() {
@@ -430,18 +534,21 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
const sorted = sortHand(hand);
const myTurn = phase === "playing" && turn === 0;
// While choosing trump the hakem must see their cards above the chooser overlay.
const choosing = phase === "choosing-trump";
const n = sorted.length;
// Compress the fan so every card fits the screen width (no overflow/scroll).
const small = vw < 560;
const size = vw < 360 ? "sm" : vw < 560 ? "md" : "lg";
const cardW = size === "sm" ? 44 : size === "md" ? 60 : 74;
const size = vw < 360 ? "sm" : vw < 480 ? "md" : vw < 640 ? "lg" : "xl";
const cardW = size === "sm" ? 44 : size === "md" ? 62 : size === "lg" ? 78 : 92;
const avail = Math.min(vw - 12, 620);
const step = n > 1 ? Math.min(cardW * 0.94, Math.max(15, (avail - cardW) / (n - 1))) : 0;
const overlap = step - cardW; // negative inline-start margin
// Desktop (with a keyboard) gets numbered shortcut badges on playable cards.
const showShortcutBadges = vw >= 768;
let playableSeq = 0;
return (
<div
className={cn(
@@ -456,6 +563,8 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
const mid = (n - 1) / 2;
const rot = (i - mid) * (small ? 2 : 3.2);
const lift = Math.abs(i - mid) * (small ? 2 : 4);
const shortcutNum = playable ? ++playableSeq : 0;
const badge = shortcutNum >= 1 && shortcutNum <= 10 ? (shortcutNum === 10 ? "0" : String(shortcutNum)) : null;
return (
<motion.button
key={card.id}
@@ -471,8 +580,9 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
data-playable={playable ? "1" : "0"}
style={{ marginInlineStart: i === 0 ? 0 : overlap }}
className={cn(
"origin-bottom shrink-0",
playable ? "cursor-pointer relative z-30" : "cursor-default"
"origin-bottom shrink-0 relative",
playable ? "cursor-pointer z-30" : "cursor-default",
playable && "card-playable"
)}
>
<PlayingCard
@@ -480,8 +590,12 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
size={size}
dimmed={dimmed}
front={front}
className={cn(playable && "ring-2 ring-gold-400/80")}
/>
{showShortcutBadges && badge && (
<span className="absolute -top-2 left-1/2 -translate-x-1/2 z-40 size-5 rounded-full btn-gold text-[11px] font-black grid place-items-center shadow-md">
{badge}
</span>
)}
</motion.button>
);
})}
@@ -490,6 +604,52 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
);
}
/* --------------------------- Shortcuts hint --------------------------- */
function ShortcutsHint() {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const vw = useViewportWidth();
if (vw < 768) return null; // keyboard shortcuts are desktop-only
return (
<div className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] ltr:left-4 rtl:right-4 z-50">
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
className="glass rounded-2xl p-3 mb-2 w-56 text-xs text-cream/80 space-y-1.5"
>
<Row k="19 / 0" v={t("keys.play")} />
<Row k="Space" v={t("keys.first")} />
<Row k="14" v={t("keys.trump")} />
<Row k="M" v={t("keys.mute")} />
<Row k="F" v={t("keys.forfeit")} />
<Row k="Esc / Q" v={t("keys.quit")} />
</motion.div>
)}
</AnimatePresence>
<button
onClick={() => setOpen((o) => !o)}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition text-gold-400 font-black"
title={t("keys.title")}
>
</button>
</div>
);
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex items-center justify-between gap-3">
<kbd className="rounded-md bg-navy-900/80 gold-border px-1.5 py-0.5 font-mono text-[10px] text-gold-300">{k}</kbd>
<span className="text-cream/70">{v}</span>
</div>
);
}
/* --------------------------- Turn indicator --------------------------- */
function TurnIndicator() {
@@ -502,19 +662,28 @@ function TurnIndicator() {
<AnimatePresence mode="wait">
<motion.div
key={game.turn}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute bottom-[120px] sm:bottom-[150px] left-1/2 -translate-x-1/2 z-30"
initial={{ opacity: 0, scale: 0.75, y: 18 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.85, y: -8 }}
transition={{ type: "spring", stiffness: 340, damping: 24 }}
className="absolute bottom-[136px] sm:bottom-[168px] left-1/2 -translate-x-1/2 z-30"
>
<div
className={cn(
"rounded-full px-4 py-1.5 text-sm font-semibold glass",
isYou ? "text-gold-300" : "text-cream/70"
)}
>
{isYou ? t("turn.you") : t("turn.other", { name })}
</div>
{isYou ? (
<motion.div
animate={{ scale: [1, 1.055, 1] }}
transition={{ repeat: Infinity, duration: 1.05, ease: "easeInOut" }}
className="btn-gold press-3d rounded-full px-7 py-2.5 font-black text-[15px] tracking-wide"
style={{
boxShadow: "0 0 0 3px rgba(212,175,55,0.55), 0 0 28px rgba(212,175,55,0.45), 0 5px 0 rgba(0,0,0,0.32)",
}}
>
{t("turn.you")}
</motion.div>
) : (
<div className="glass rounded-full px-5 py-2 text-sm font-semibold text-cream/70">
{t("turn.other", { name })}
</div>
)}
</motion.div>
</AnimatePresence>
);
@@ -525,10 +694,12 @@ function TurnIndicator() {
function TurnTimer() {
const deadline = useGameStore((s) => s.turnDeadline);
const phase = useGameStore((s) => s.game.phase);
const stake = useGameStore((s) => s.matchMeta.stake);
const speed = useGameStore((s) => s.matchMeta.speed);
const secs = useCountdown(deadline);
if (deadline == null || secs == null) return null;
if (phase !== "playing" && phase !== "choosing-trump") return null;
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / TURN_MS));
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / turnMsForStake(stake, speed)));
const danger = secs <= 5;
return (
<div className="absolute bottom-[156px] sm:bottom-[190px] left-1/2 -translate-x-1/2 z-30 w-36 sm:w-40 text-center">
@@ -808,6 +979,15 @@ function TrumpChooser() {
);
}
const CONFETTI_SPECS = Array.from({ length: 22 }, (_, i) => ({
id: i,
left: 4 + ((i * 4.3) % 92),
delay: (i * 0.07) % 1.2,
color: i % 4 === 0 ? "#d4af37" : i % 4 === 1 ? "#2dd4bf" : i % 4 === 2 ? "#f5ecd6" : "#fb7185",
size: 6 + (i % 4) * 3,
rot: (i * 41) % 360,
}));
function RoundOverlay() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
@@ -817,43 +997,68 @@ function RoundOverlay() {
return (
<Backdrop>
<motion.div
initial={{ scale: 0.85, y: 20 }}
initial={{ scale: 0.82, y: 24 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-8 text-center max-w-sm w-full"
transition={{ type: "spring", stiffness: 220, damping: 20 }}
className="glass rounded-3xl p-8 text-center max-w-sm w-full relative overflow-hidden"
>
{/* confetti on win */}
{weWon && CONFETTI_SPECS.map(p => (
<motion.div
key={p.id}
initial={{ y: -10, opacity: 1, rotate: p.rot }}
animate={{ y: 160, opacity: 0, rotate: p.rot + 450 }}
transition={{ duration: 1.5 + p.delay * 0.4, delay: p.delay, ease: "linear" }}
className="absolute pointer-events-none rounded-sm"
style={{ width: p.size, height: p.size, background: p.color, left: `${p.left}%`, top: 0 }}
/>
))}
<motion.div
initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 16 }}
className="text-5xl mb-1"
>
{weWon ? "🎉" : "😤"}
</motion.div>
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
{r.kot && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.15 }}
className="mt-3 inline-block rounded-full btn-gold px-5 py-1.5 text-lg font-black"
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 350, damping: 14, delay: 0.15 }}
className="mt-3 inline-flex items-center gap-1.5 rounded-full btn-gold press-3d px-6 py-2 text-xl font-black"
>
{t("round.kot")}🔥
{t("round.kot")} 🔥
</motion.div>
)}
<p
className={cn(
"mt-4 text-xl font-bold",
weWon ? "text-teal-300" : "text-rose-300"
)}
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.22 }}
className={cn("mt-4 text-2xl font-black", weWon ? "text-teal-300" : "text-rose-300")}
>
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
</motion.p>
<p className="text-cream/70 mt-2 font-semibold">
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
</p>
<p className="text-cream/70 mt-2">
{t("round.score", {
us: game.matchScore[0],
them: game.matchScore[1],
})}
</p>
<p className="text-cream/40 text-sm mt-5 animate-pulse">
{t("round.next")}
</p>
<p className="text-cream/40 text-sm mt-5 animate-pulse">{t("round.next")}</p>
</motion.div>
</Backdrop>
);
}
const WIN_COINS = Array.from({ length: 14 }, (_, i) => ({
id: i,
left: 5 + ((i * 6.8) % 88),
delay: (i * 0.11) % 1.6,
fontSize: 18 + (i % 3) * 10,
}));
function MatchOverlay({ onExit }: { onExit: () => void }) {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
@@ -861,37 +1066,46 @@ function MatchOverlay({ onExit }: { onExit: () => void }) {
return (
<Backdrop>
<motion.div
initial={{ scale: 0.85 }}
animate={{ scale: 1 }}
className="glass rounded-3xl p-9 text-center max-w-sm w-full"
initial={{ scale: 0.82, y: 16 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 18 }}
className="glass rounded-3xl p-9 text-center max-w-sm w-full relative overflow-hidden"
>
{/* coin rain on win */}
{youWin && WIN_COINS.map(c => (
<motion.div
key={c.id}
initial={{ top: "-30px", opacity: 0, rotate: 0 }}
animate={{ top: "110%", opacity: [0, 1, 1, 0], rotate: 540 }}
transition={{ duration: 2.2 + c.delay * 0.3, delay: c.delay, ease: "easeIn" }}
className="absolute pointer-events-none select-none"
style={{ left: `${c.left}%`, fontSize: c.fontSize }}
>
🪙
</motion.div>
))}
<motion.div
initial={{ rotate: -15, scale: 0 }}
initial={{ rotate: -20, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 160 }}
className="text-6xl mb-3"
transition={{ type: "spring", stiffness: 180, damping: 14 }}
className="text-7xl mb-3 relative"
>
{youWin ? "🏆" : "🎴"}
</motion.div>
<h2 className="gold-text text-3xl font-black">{t("match.over")}</h2>
<p
className={cn(
"mt-3 text-2xl font-bold",
youWin ? "text-gold-300" : "text-rose-300"
)}
>
<p className={cn("mt-3 text-2xl font-bold", youWin ? "text-gold-300" : "text-rose-300")}>
{youWin ? t("match.youWin") : t("match.youLose")}
</p>
<p className="text-cream/70 mt-2">
{t("round.score", {
us: game.matchScore[0],
them: game.matchScore[1],
})}
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
</p>
<MatchPlayersList />
<div className="mt-7 flex gap-3">
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3">
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3 font-black text-lg">
{t("match.menu")}
</button>
</div>