Files
HokmPlay/src/components/GameTable.tsx
T
soroush.asadi dcea0bc87c
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m15s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 4m39s
fix: auto-recover from stale-bundle chunk errors; responsive touch-ups
- The "This page couldn't load" after a redeploy was a stale bundle: a tab open
  across a deploy requests JS chunks that no longer exist (ChunkLoadError). Added
  a global error/unhandledrejection guard that reloads once to fetch the fresh
  bundle (sessionStorage-guarded against loops, cleared after a healthy run).
- Reaction tray width → w-[min(270px,86vw)] so it never overflows narrow phones.

Verified: tsc + next build pass; web image rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:51:36 +03:30

1118 lines
39 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { AnimatePresence, motion } from "framer-motion";
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";
import {
Card,
Seat,
Suit,
SUITS,
SUIT_IS_RED,
SUIT_SYMBOL,
teamOf,
} from "@/lib/hokm/types";
import { useI18n } from "@/lib/i18n";
import { useSessionStore } from "@/lib/session-store";
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";
import { Sticker } from "./online/Sticker";
import { MatchPlayersList } from "./online/MatchPlayersList";
function useCountdown(deadline: number | null) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (deadline == null) return;
const id = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(id);
}, [deadline]);
if (deadline == null) return null;
return Math.max(0, Math.ceil((deadline - now) / 1000));
}
function useCardSkins() {
const frontId = useSessionStore((s) => s.profile?.cardFront ?? "classic");
const backId = useSessionStore((s) => s.profile?.cardBack ?? "classic");
const f = cardFrontById(frontId);
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, pattern: b.pattern, motif: b.motif },
};
}
export function GameTable({
onExit,
onForfeit,
}: { onExit?: () => void; onForfeit?: () => void } = {}) {
const game = useGameStore((s) => s.game);
const reset = useGameStore((s) => s.reset);
const mode = useGameStore((s) => s.mode);
const { t } = useI18n();
const [askFf, setAskFf] = useState(false);
const sfx = useSoundStore((s) => s.sfx);
const music = useSoundStore((s) => s.music);
const toggleAll = useSoundStore((s) => s.toggleAll);
const muted = !sfx && !music;
const exit = onExit ?? reset;
const vw = useViewportWidth();
// Pull the played-card pile inward on narrow screens so it clears the side stacks.
const trickScale = vw < 360 ? 0.5 : vw < 460 ? 0.64 : 1;
const { phase, players, hakem, trump, turn, currentTrick } = game;
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">
{/* Top HUD */}
<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}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("settings.audio")}
>
{muted ? (
<VolumeX className="size-4 text-cream/60" />
) : (
<Volume2 className="size-4 text-gold-400" />
)}
</button>
{onForfeit && (
<button
onClick={() => setAskFf(true)}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("forfeit.title")}
>
<Flag className="size-4 text-rose-300/90" />
</button>
)}
<button
onClick={exit}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("hud.quit")}
>
<LogOut className="size-4 text-cream/80" />
</button>
</div>
</div>
{/* forfeit confirm (requester) */}
<AnimatePresence>
{askFf && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
>
<div className="glass rounded-3xl p-6 w-full max-w-xs text-center">
<div className="text-4xl mb-2">🏳</div>
<h2 className="gold-text text-xl font-black">{t("forfeit.title")}</h2>
<p className="text-cream/70 text-sm mt-2">{t("forfeit.ask")}</p>
<p className="text-cream/45 text-xs mt-1">{t("forfeit.rule")}</p>
<div className="flex gap-2 mt-5">
<button
onClick={() => {
setAskFf(false);
onForfeit?.();
}}
className="flex-1 rounded-xl py-3 bg-rose-500/80 text-white font-bold"
>
{t("forfeit.confirm")}
</button>
<button onClick={() => setAskFf(false)} className="flex-1 btn-gold rounded-xl py-3">
{t("forfeit.keepPlaying")}
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Felt table */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="felt relative w-[min(94vw,1100px)] h-[min(82vh,720px)] rounded-[42%]">
{/* opponent + partner seats */}
<SeatAvatar seat={2} className="absolute top-3 left-1/2 -translate-x-1/2" />
<SeatAvatar seat={1} className="absolute top-1/2 right-3 -translate-y-1/2" />
<SeatAvatar seat={3} className="absolute top-1/2 left-3 -translate-y-1/2" />
{/* opponents' face-down hands */}
<OpponentHand seat={2} className="absolute top-16 sm:top-20 left-1/2 -translate-x-1/2" horizontal />
<OpponentHand seat={1} className="absolute top-1/2 right-14 sm:right-16 -translate-y-1/2" />
<OpponentHand seat={3} className="absolute top-1/2 left-14 sm:left-16 -translate-y-1/2" />
{/* center trick area (offsets scale down on narrow screens) */}
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} scale={trickScale} />
</div>
</div>
{/* Your hand */}
<PlayerHand legalIds={legalIds} />
{/* Turn indicator + timer — stacked so they never overlap */}
<div className="absolute bottom-[140px] sm:bottom-[172px] left-1/2 -translate-x-1/2 z-30 flex flex-col items-center gap-2.5 pointer-events-none">
<TurnTimer />
<TurnIndicator />
</div>
<DisconnectBanner />
<Reactions />
<ShortcutsHint />
{/* Overlays */}
<AnimatePresence>
{phase === "selecting-hakem" && <HakemOverlay key="hakem" />}
{phase === "choosing-trump" && players[hakem!]?.isHuman && (
<TrumpChooser key="trump" />
)}
{phase === "round-over" && <RoundOverlay key="round" />}
{phase === "match-over" && mode === "ai" && (
<MatchOverlay key="match" onExit={exit} />
)}
</AnimatePresence>
</main>
);
}
/* ----------------------------- Scoreboard ----------------------------- */
function Scoreboard() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
return (
<div className="glass rounded-2xl px-2.5 py-1.5 gap-2.5 sm:px-4 sm:py-2.5 sm:gap-4 flex items-center">
<ScoreCol
label={t("team.us")}
tricks={game.roundTricks[0]}
score={game.matchScore[0]}
accent="text-teal-400"
/>
<div className="text-cream/30 text-lg font-light">/</div>
<ScoreCol
label={t("team.them")}
tricks={game.roundTricks[1]}
score={game.matchScore[1]}
accent="text-rose-400"
/>
<div className="ltr:ml-2 rtl:mr-2 ltr:border-l rtl:border-r border-gold-500/20 ltr:pl-3 rtl:pr-3">
<div className="text-[10px] text-cream/50">{t("home.target")}</div>
<div className="gold-text font-bold text-center">{game.targetScore}</div>
</div>
</div>
);
}
function ScoreCol({
label,
tricks,
score,
accent,
}: {
label: string;
tricks: number;
score: number;
accent: string;
}) {
const { t } = useI18n();
return (
<div className="text-center min-w-10 sm:min-w-14">
<div className={cn("text-[11px] sm:text-xs font-semibold", accent)}>{label}</div>
<div className="text-lg sm:text-2xl font-black leading-none">{score}</div>
<div className="text-[10px] text-cream/45 mt-0.5">
{t("score.tricks")}: {tricks}
</div>
</div>
);
}
/* ----------------------------- 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 }) {
const { t } = useI18n();
const red = SUIT_IS_RED[trump];
return (
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
className="glass rounded-2xl px-3 py-2 flex items-center gap-2"
>
<span className="text-[10px] text-gold-400 font-semibold">
{t("trump.label")}
</span>
<span
className={cn(
"text-2xl leading-none",
red ? "text-rose-400" : "text-cream"
)}
>
{SUIT_SYMBOL[trump]}
</span>
</motion.div>
);
}
/* ----------------------------- Seat avatar ---------------------------- */
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) ||
(game.phase === "choosing-trump" && game.hakem === seat);
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)}>
<div
className={cn(
"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-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
)}
{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-[10px] text-gold-300/80 leading-none hud-shadow">{`Lv ${sp.level}`}</span>
)}
</div>
);
}
/* --------------------------- Opponent hands --------------------------- */
function OpponentHand({
seat,
className,
horizontal,
}: {
seat: Seat;
className?: string;
horizontal?: boolean;
}) {
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 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>
);
}
/* ----------------------------- Trick area ----------------------------- */
const TRICK_OFFSET: Record<Seat, { x: number; y: number }> = {
0: { x: 0, y: 70 },
1: { x: 96, y: 0 },
2: { x: 0, y: -70 },
3: { x: -96, y: 0 },
};
const TRICK_ENTER: Record<Seat, { x: number; y: number }> = {
0: { x: 0, y: 260 },
1: { x: 360, y: 0 },
2: { x: 0, y: -260 },
3: { x: -360, y: 0 },
};
function TrickArea({
trick,
winner,
phase,
scale = 1,
}: {
trick: { seat: Seat; card: Card }[];
winner: Seat | null;
phase: string;
scale?: number;
}) {
const { front } = useCardSkins();
return (
<div className="absolute inset-0 flex items-center justify-center">
<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;
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.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 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
: undefined,
}}
>
<PlayingCard card={pc.card} size="md" front={front} />
</motion.div>
);
})}
</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() {
const [vw, setVw] = useState(typeof window !== "undefined" ? window.innerWidth : 390);
useEffect(() => {
const f = () => setVw(window.innerWidth);
f();
window.addEventListener("resize", f);
window.addEventListener("orientationchange", f);
return () => {
window.removeEventListener("resize", f);
window.removeEventListener("orientationchange", f);
};
}, []);
return vw;
}
function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
const hand = useGameStore((s) => s.game.players[0].hand);
const phase = useGameStore((s) => s.game.phase);
const turn = useGameStore((s) => s.game.turn);
const playHuman = useGameStore((s) => s.playHuman);
const { front } = useCardSkins();
const vw = useViewportWidth();
const sorted = sortHand(hand);
const myTurn = phase === "playing" && turn === 0;
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 < 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(
"absolute bottom-0 inset-x-0 flex justify-center safe-bottom pointer-events-none",
choosing ? "z-50" : "z-20"
)}
>
<div className="relative flex items-end justify-center pointer-events-auto max-w-full">
{sorted.map((card, i) => {
const playable = myTurn && legalIds.has(card.id);
const dimmed = myTurn && !playable;
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}
layout
initial={{ y: 120, opacity: 0 }}
animate={{ y: lift, opacity: 1, rotate: rot }}
transition={{ type: "spring", stiffness: 280, damping: 28, delay: i * 0.012 }}
whileHover={playable ? { y: lift - 26, scale: 1.08, zIndex: 50 } : {}}
whileTap={playable ? { scale: 1.05 } : {}}
onClick={() => playable && playHuman(card)}
disabled={!playable}
data-card={card.id}
data-playable={playable ? "1" : "0"}
style={{ marginInlineStart: i === 0 ? 0 : overlap }}
className={cn(
"origin-bottom shrink-0 relative",
playable ? "cursor-pointer z-30" : "cursor-default",
playable && "card-playable"
)}
>
<PlayingCard
card={card}
size={size}
dimmed={dimmed}
front={front}
/>
{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>
);
})}
</div>
</div>
);
}
/* --------------------------- 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() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
if (game.phase !== "playing" || game.turn == null) return null;
const isYou = game.turn === 0;
const name = game.players[game.turn].name;
return (
<AnimatePresence mode="wait">
<motion.div
key={game.turn}
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="pointer-events-none"
>
{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>
);
}
/* ----------------------------- Turn timer ----------------------------- */
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()) / turnMsForStake(stake, speed)));
const danger = secs <= 5;
return (
<div className="w-36 sm:w-40 text-center">
<span
className={cn(
"block text-sm font-black tabular-nums mb-1",
danger ? "text-rose-300 animate-pulse" : "text-gold-300"
)}
>
{secs}
</span>
<div className="h-1.5 rounded-full bg-navy-900/70 overflow-hidden gold-border">
<div
className="h-full rounded-full"
style={{
width: `${pct * 100}%`,
background: danger
? "#fb7185"
: "linear-gradient(90deg, var(--gold-500), var(--gold-300))",
}}
/>
</div>
</div>
);
}
function DisconnectBanner() {
const seat = useGameStore((s) => s.disconnectedSeat);
const deadline = useGameStore((s) => s.reconnectDeadline);
const name = useGameStore((s) => (seat != null ? s.seatPlayers[seat]?.name : null));
const secs = useCountdown(deadline);
const { t } = useI18n();
return (
<AnimatePresence>
{seat != null && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-40"
>
<div className="glass rounded-2xl px-4 py-3 flex items-center gap-2.5 text-sm">
<WifiOff className="size-5 text-rose-300 animate-pulse" />
<span className="text-cream/90">
{t("dc.waiting", { name: name ?? "", s: secs ?? 0 })}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
/* ----------------------------- Reactions ------------------------------ */
const REACTION_POS: Record<number, string> = {
0: "bottom-44 left-1/2 -translate-x-1/2",
1: "top-1/2 right-20 -translate-y-1/2",
2: "top-28 left-1/2 -translate-x-1/2",
3: "top-1/2 left-20 -translate-y-1/2",
};
interface Bubble {
id: string;
seat: number;
emoji: string;
}
function ReactionBubble({ value }: { value: string }) {
if (value.startsWith("sticker:")) {
return <Sticker id={value.slice(8)} size={72} className="drop-shadow-xl" />;
}
return <span className="text-4xl drop-shadow-lg">{value}</span>;
}
function Reactions() {
const profile = useSessionStore((s) => s.profile);
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"emoji" | "sticker">("emoji");
const [bubbles, setBubbles] = useState<Bubble[]>([]);
const emojis = profile ? ownedReactions(profile) : [];
const stickers = profile ? ownedStickers(profile) : [];
useEffect(() => {
const unsub = getService().onReaction((seat, emoji) => {
const id = `${seat}-${Date.now()}-${Math.random()}`;
setBubbles((b) => [...b, { id, seat, emoji }]);
setTimeout(() => setBubbles((b) => b.filter((x) => x.id !== id)), 2600);
});
return unsub;
}, []);
const send = (value: string) => {
getService().sendReaction(value);
setOpen(false);
};
return (
<>
{/* floating bubbles */}
{bubbles.map((b) => (
<motion.div
key={b.id}
initial={{ opacity: 0, scale: 0.4, y: 10 }}
animate={{ opacity: 1, scale: 1, y: -18 }}
exit={{ opacity: 0 }}
className={cn("absolute z-40 pointer-events-none", REACTION_POS[b.seat])}
>
<ReactionBubble value={b.emoji} />
</motion.div>
))}
{/* tray */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.95 }}
className="absolute bottom-20 ltr:right-4 rtl:left-4 z-50 glass rounded-2xl p-2 w-[min(270px,86vw)]"
>
<div className="flex gap-1 p-1 rounded-xl bg-navy-900/70 mb-2">
<button
onClick={() => setTab("emoji")}
className={cn(
"flex-1 rounded-lg py-1.5 text-xs font-bold transition",
tab === "emoji" ? "btn-gold" : "text-cream/60"
)}
>
{t("reactions.title")}
</button>
<button
onClick={() => setTab("sticker")}
className={cn(
"flex-1 rounded-lg py-1.5 text-xs font-bold transition",
tab === "sticker" ? "btn-gold" : "text-cream/60"
)}
>
{t("stickers.title")}
</button>
</div>
{tab === "emoji" ? (
<div className="grid grid-cols-5 gap-1">
{emojis.map((emoji, i) => (
<button
key={`${emoji}-${i}`}
onClick={() => send(emoji)}
className="size-10 rounded-xl hover:bg-navy-800 transition flex items-center justify-center text-2xl"
>
{emoji}
</button>
))}
</div>
) : (
<div className="grid grid-cols-4 gap-1">
{stickers.map((id) => (
<button
key={id}
onClick={() => send(`sticker:${id}`)}
className="rounded-xl hover:bg-navy-800 transition flex items-center justify-center p-1"
>
<Sticker id={id} size={48} />
</button>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
{/* button */}
<button
onClick={() => setOpen((o) => !o)}
className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] ltr:right-4 rtl:left-4 z-50 glass rounded-full min-h-12 min-w-12 grid place-items-center hover:bg-navy-800 transition"
title={t("reactions.title")}
>
<SmilePlus className="size-5 text-gold-400" />
</button>
</>
);
}
/* ------------------------------ Overlays ------------------------------ */
function Backdrop({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-40 flex items-center justify-center bg-navy-950/70 backdrop-blur-sm p-5"
>
{children}
</motion.div>
);
}
function HakemOverlay() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
const { front } = useCardSkins();
const hakemName = game.hakem != null ? game.players[game.hakem].name : "";
return (
<Backdrop>
<motion.div
initial={{ scale: 0.9, y: 10 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-7 text-center max-w-sm w-full"
>
<h2 className="gold-text text-2xl font-black">{t("hakem.title")}</h2>
<p className="text-cream/60 text-sm mt-1">{t("hakem.desc")}</p>
<div className="flex flex-wrap justify-center gap-1.5 mt-5">
{game.hakemDraw.map((pc, i) => (
<motion.div
key={pc.card.id}
initial={{ opacity: 0, y: -20, rotateY: 90 }}
animate={{ opacity: 1, y: 0, rotateY: 0 }}
transition={{ delay: i * 0.12 }}
>
<PlayingCard card={pc.card} size="sm" front={front} />
</motion.div>
))}
</div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: game.hakemDraw.length * 0.12 + 0.2 }}
className="mt-5 text-gold-300 font-bold text-lg flex items-center justify-center gap-2"
>
<Crown className="size-5 text-gold-400 fill-gold-500" />
{t("hakem.is", { name: hakemName })}
</motion.p>
</motion.div>
</Backdrop>
);
}
function TrumpChooser() {
const choose = useGameStore((s) => s.chooseTrump);
const { t } = useI18n();
return (
<Backdrop>
<motion.div
initial={{ scale: 0.9, y: 10 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-7 text-center max-w-sm w-full"
>
<h2 className="gold-text text-2xl font-black">{t("trump.title")}</h2>
<p className="text-cream/60 text-sm mt-1">{t("trump.desc")}</p>
<div className="grid grid-cols-2 gap-3 mt-6">
{SUITS.map((suit) => {
const red = SUIT_IS_RED[suit];
return (
<motion.button
key={suit}
whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.96 }}
onClick={() => choose(suit)}
className="rounded-2xl bg-navy-900/80 gold-border py-6 flex items-center justify-center hover:bg-navy-800 transition"
>
<span
className={cn(
"text-5xl",
red ? "text-rose-400" : "text-cream"
)}
>
{SUIT_SYMBOL[suit]}
</span>
</motion.button>
);
})}
</div>
</motion.div>
</Backdrop>
);
}
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();
const r = game.lastRoundResult;
if (!r) return null;
const weWon = r.winningTeam === 0;
return (
<Backdrop>
<motion.div
initial={{ scale: 0.82, y: 24 }}
animate={{ scale: 1, y: 0 }}
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, 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")} 🔥
</motion.div>
)}
<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/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();
const youWin = game.matchWinner === 0;
return (
<Backdrop>
<motion.div
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: -20, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
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")}>
{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] })}
</p>
<MatchPlayersList />
<div className="mt-7 flex gap-3">
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3 font-black text-lg">
{t("match.menu")}
</button>
</div>
</motion.div>
</Backdrop>
);
}