Turn timer + auto-play, disconnect/reconnect, cosmetics, queue & paid plan

- Turn timer (20s) for play/trump; system auto-plays a smart move on timeout
- Disconnect handling (mock): wait-for-return countdown, system covers turns
- Cosmetics: titles, card-back styles, custom profile-image upload, badges;
  pickers in Profile; shop sells card styles; reward modal shows new titles
- Paid plan (pro): free players queue when server busy, pro skips; upgrade flow
- OnlineService extended (upgradePlan, richer profile patch); mock implements
  queue + plans; gamification adds TITLES + CARD_STYLES

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 10:49:54 +03:30
parent 5776036d78
commit 13ec0d4300
16 changed files with 682 additions and 61 deletions
+84 -3
View File
@@ -1,8 +1,9 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Crown, LogOut } from "lucide-react";
import { useGameStore } from "@/lib/game-store";
import { Crown, LogOut, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { TURN_MS, useGameStore } from "@/lib/game-store";
import { legalMoves } from "@/lib/hokm/engine";
import { sortHand } from "@/lib/hokm/deck";
import {
@@ -15,9 +16,22 @@ import {
teamOf,
} from "@/lib/hokm/types";
import { useI18n } from "@/lib/i18n";
import { useSessionStore } from "@/lib/session-store";
import { cardStyleById } from "@/lib/online/gamification";
import { cn } from "@/lib/cn";
import { PlayingCard } from "./PlayingCard";
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));
}
export function GameTable({ onExit }: { onExit?: () => void } = {}) {
const game = useGameStore((s) => s.game);
const reset = useGameStore((s) => s.reset);
@@ -73,6 +87,8 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) {
{/* Turn indicator */}
<TurnIndicator />
<TurnTimer />
<DisconnectBanner />
{/* Overlays */}
<AnimatePresence>
@@ -220,6 +236,9 @@ function OpponentHand({
horizontal?: boolean;
}) {
const count = useGameStore((s) => s.game.players[seat].hand.length);
const styleId = useSessionStore((s) => s.profile?.cardStyle ?? "classic");
const cs = cardStyleById(styleId);
const back = { c1: cs.c1, c2: cs.c2, accent: cs.accent };
const cards = Array.from({ length: count });
return (
<div
@@ -234,7 +253,7 @@ function OpponentHand({
key={i}
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
>
<PlayingCard faceDown size="sm" />
<PlayingCard faceDown size="sm" back={back} />
</div>
))}
</div>
@@ -387,6 +406,68 @@ function TurnIndicator() {
);
}
/* ----------------------------- Turn timer ----------------------------- */
function TurnTimer() {
const deadline = useGameStore((s) => s.turnDeadline);
const phase = useGameStore((s) => s.game.phase);
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 danger = secs <= 5;
return (
<div className="absolute bottom-[190px] left-1/2 -translate-x-1/2 z-30 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>
);
}
/* ------------------------------ Overlays ------------------------------ */
function Backdrop({ children }: { children: React.ReactNode }) {