Build Hokm card game: offline vs-AI + online social/gamification (mock backend)

- Pure-TS Hokm engine (deal, hakem, trump, tricks, scoring, Kot) + AI bots
- Persian-luxury RTL UI (Next 16 / React 19 / Tailwind v4 / Framer Motion / Zustand)
- Online platform behind OnlineService seam (mock now, .NET SignalR later):
  auth (phone OTP + email/Google), profiles, friends, private rooms with
  partner pick, ranked matchmaking, leaderboard, shop
- Gamification: ranks/leagues, coins, XP/levels, daily rewards, achievements
- i18n fa/en, PWA manifest, engine + gamification sims

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 10:11:00 +03:30
parent dff1a34f95
commit e2d0a602b6
41 changed files with 5766 additions and 93 deletions
+572
View File
@@ -0,0 +1,572 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Crown, LogOut } from "lucide-react";
import { useGameStore } from "@/lib/game-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 { cn } from "@/lib/cn";
import { PlayingCard } from "./PlayingCard";
export function GameTable({ onExit }: { onExit?: () => void } = {}) {
const game = useGameStore((s) => s.game);
const reset = useGameStore((s) => s.reset);
const mode = useGameStore((s) => s.mode);
const { t } = useI18n();
const exit = onExit ?? reset;
const { phase, players, hakem, trump, turn, currentTrick } = game;
const legalIds = new Set(
phase === "playing" && turn === 0
? legalMoves(game, 0).map((c) => c.id)
: []
);
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 p-3 sm:p-4">
<Scoreboard />
<div className="flex items-center gap-2">
{trump && <TrumpBadge trump={trump} />}
<button
onClick={exit}
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
title={t("hud.quit")}
>
<LogOut className="size-4 text-cream/80" />
</button>
</div>
</div>
{/* 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-20 left-1/2 -translate-x-1/2" horizontal />
<OpponentHand seat={1} className="absolute top-1/2 right-16 -translate-y-1/2" />
<OpponentHand seat={3} className="absolute top-1/2 left-16 -translate-y-1/2" />
{/* center trick area */}
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} />
</div>
</div>
{/* Your hand */}
<PlayerHand legalIds={legalIds} />
{/* Turn indicator */}
<TurnIndicator />
{/* 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-4 py-2.5 flex items-center gap-4">
<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-14">
<div className={cn("text-xs font-semibold", accent)}>{label}</div>
<div className="text-2xl font-black leading-none">{score}</div>
<div className="text-[10px] text-cream/45 mt-0.5">
{t("score.tricks")}: {tricks}
</div>
</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 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;
return (
<div className={cn("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)" }
}
className={cn(
"relative size-12 rounded-full flex items-center justify-center font-bold text-xl",
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100"
)}
>
{sp?.avatar ?? name.charAt(0)}
{isHakem && (
<Crown className="absolute -top-3 size-4 text-gold-400 fill-gold-500" />
)}
</motion.div>
<span className="text-[11px] text-cream/80 max-w-20 truncate">{name}</span>
{sp && sp.level > 0 && (
<span className="text-[9px] text-gold-400/80 leading-none">
{team === 0 ? "" : ""}
{`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 cards = Array.from({ length: count });
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" />
</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,
}: {
trick: { seat: Seat; card: Card }[];
winner: Seat | null;
phase: string;
}) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-1 ">
<AnimatePresence>
{trick.map((pc) => {
const off = TRICK_OFFSET[pc.seat];
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.12 : 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))"
: undefined,
}}
>
<PlayingCard card={pc.card} size="md" />
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
);
}
/* ----------------------------- Player hand ---------------------------- */
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 sorted = sortHand(hand);
const myTurn = phase === "playing" && turn === 0;
const n = sorted.length;
return (
<div className="absolute bottom-0 inset-x-0 z-20 flex justify-center pb-3 pointer-events-none">
<div className="relative flex items-end justify-center pointer-events-auto">
{sorted.map((card, i) => {
const playable = myTurn && legalIds.has(card.id);
const dimmed = myTurn && !playable;
const mid = (n - 1) / 2;
const rot = (i - mid) * 3.2;
const lift = Math.abs(i - mid) * 4;
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.015 }}
whileHover={playable ? { y: lift - 26, scale: 1.06, zIndex: 50 } : {}}
onClick={() => playable && playHuman(card)}
disabled={!playable}
data-card={card.id}
data-playable={playable ? "1" : "0"}
style={{ marginInlineStart: i === 0 ? 0 : -22 }}
className={cn(
"origin-bottom",
playable && "cursor-pointer",
!myTurn && "cursor-default"
)}
>
<PlayingCard
card={card}
size="lg"
dimmed={dimmed}
className={cn(playable && "ring-2 ring-gold-400/70")}
/>
</motion.button>
);
})}
</div>
</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, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute bottom-[150px] 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>
</motion.div>
</AnimatePresence>
);
}
/* ------------------------------ 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 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" />
</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>
);
}
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.85, y: 20 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-8 text-center max-w-sm w-full"
>
<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"
>
{t("round.kot")}🔥
</motion.div>
)}
<p
className={cn(
"mt-4 text-xl font-bold",
weWon ? "text-teal-300" : "text-rose-300"
)}
>
{t("round.won", { team: weWon ? t("team.0") : t("team.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>
</motion.div>
</Backdrop>
);
}
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.85 }}
animate={{ scale: 1 }}
className="glass rounded-3xl p-9 text-center max-w-sm w-full"
>
<motion.div
initial={{ rotate: -15, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 160 }}
className="text-6xl mb-3"
>
{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>
<div className="mt-7 flex gap-3">
<button onClick={onExit} className="btn-gold flex-1 rounded-xl py-3">
{t("match.menu")}
</button>
</div>
</motion.div>
</Backdrop>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { motion } from "framer-motion";
import {
Bot,
Globe,
LogIn,
LogOut,
ShoppingBag,
Trophy,
User,
Users,
Wifi,
} from "lucide-react";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { SUIT_SYMBOL } from "@/lib/hokm/types";
import { TopBar } from "./online/TopBar";
export function HomeScreen() {
const { t, toggle } = useI18n();
const newMatch = useGameStore((s) => s.newMatch);
const goGame = useUIStore((s) => s.goGame);
const go = useUIStore((s) => s.go);
const profile = useSessionStore((s) => s.profile);
const isAuthed = useSessionStore((s) => s.isAuthed);
const signOut = useSessionStore((s) => s.signOut);
const playVsComputer = () => {
const you = profile?.displayName || t("seat.you");
newMatch({ names: [you, "آرش", "کیان", "نیلوفر"], targetScore: 7 });
goGame("home");
};
const playOnline = () => (isAuthed ? go("online") : go("auth"));
return (
<main className="persian-pattern relative min-h-dvh w-full overflow-y-auto">
<FloatingSuits />
<div className="relative z-10 mx-auto w-full max-w-md p-4 sm:p-6 flex flex-col min-h-dvh">
<div className="pt-1">
<TopBar />
</div>
{/* logo */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center text-center mt-6 mb-7"
>
<div className="size-16 rounded-2xl gold-border flex items-center justify-center bg-navy-900 mb-3 shadow-lg">
<span className="gold-text text-4xl font-black leading-none"></span>
</div>
<h1 className="gold-text text-5xl font-black tracking-tight">
{t("app.title")}
</h1>
<p className="text-cream/60 mt-1 text-sm">{t("app.subtitle")}</p>
</motion.div>
{/* primary actions */}
<div className="space-y-3">
<PrimaryCard
icon={<Wifi className="size-6" />}
title={t("menu.online")}
desc={t("menu.onlineDesc")}
onClick={playOnline}
primary
/>
<PrimaryCard
icon={<Bot className="size-6" />}
title={t("menu.vsComputer")}
desc={t("menu.vsComputerDesc")}
onClick={playVsComputer}
/>
</div>
{/* tiles */}
<div className="grid grid-cols-4 gap-2.5 mt-4">
<Tile icon={<User className="size-5" />} label={t("menu.profile")} onClick={() => go("profile")} />
<Tile icon={<Users className="size-5" />} label={t("menu.friends")} onClick={() => go(isAuthed ? "friends" : "auth")} />
<Tile icon={<Trophy className="size-5" />} label={t("menu.leaderboard")} onClick={() => go("leaderboard")} />
<Tile icon={<ShoppingBag className="size-5" />} label={t("menu.shop")} onClick={() => go("shop")} />
</div>
<div className="flex-1" />
{/* footer */}
<div className="flex items-center justify-between gap-2 pt-6 pb-2">
{isAuthed ? (
<button
onClick={signOut}
className="glass rounded-full px-4 py-2 text-sm flex items-center gap-2 hover:bg-navy-800/80 transition"
>
<LogOut className="size-4 text-rose-300" />
{t("menu.signOut")}
</button>
) : (
<button
onClick={() => go("auth")}
className="btn-gold rounded-full px-4 py-2 text-sm flex items-center gap-2"
>
<LogIn className="size-4" />
{t("menu.signIn")}
</button>
)}
<button
onClick={toggle}
className="glass rounded-full px-4 py-2 text-sm flex items-center gap-2 hover:bg-navy-800/80 transition"
>
<Globe className="size-4 text-gold-400" />
{t("home.lang")}
</button>
</div>
</div>
</main>
);
}
function PrimaryCard({
icon,
title,
desc,
onClick,
primary,
}: {
icon: React.ReactNode;
title: string;
desc: string;
onClick: () => void;
primary?: boolean;
}) {
return (
<motion.button
whileTap={{ scale: 0.98 }}
whileHover={{ y: -2 }}
onClick={onClick}
className={
"w-full rounded-2xl p-4 flex items-center gap-4 text-start transition " +
(primary
? "btn-gold"
: "glass hover:bg-navy-800/80")
}
>
<span
className={
"size-12 rounded-xl flex items-center justify-center shrink-0 " +
(primary ? "bg-black/15 text-[#2a1f04]" : "bg-navy-900 gold-border text-gold-400")
}
>
{icon}
</span>
<span>
<span className={"block text-lg font-black " + (primary ? "text-[#2a1f04]" : "text-cream")}>
{title}
</span>
<span className={"block text-xs " + (primary ? "text-[#2a1f04]/70" : "text-cream/55")}>
{desc}
</span>
</span>
</motion.button>
);
}
function Tile({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<motion.button
whileTap={{ scale: 0.95 }}
onClick={onClick}
className="glass rounded-2xl py-3 flex flex-col items-center gap-1.5 hover:bg-navy-800/80 transition"
>
<span className="text-gold-400">{icon}</span>
<span className="text-[11px] text-cream/80">{label}</span>
</motion.button>
);
}
function FloatingSuits() {
const suits = Object.values(SUIT_SYMBOL);
const items = Array.from({ length: 8 }, (_, i) => ({
s: suits[i % 4],
left: `${(i * 13 + 6) % 95}%`,
delay: i * 0.7,
dur: 9 + (i % 4) * 2,
size: 28 + (i % 3) * 18,
}));
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{items.map((it, i) => (
<span
key={i}
className="float-suit absolute text-gold-500/10 font-black"
style={{
left: it.left,
fontSize: it.size,
animationDuration: `${it.dur}s`,
animationDelay: `${it.delay}s`,
}}
>
{it.s}
</span>
))}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { cn } from "@/lib/cn";
import { Card, SUIT_IS_RED, SUIT_SYMBOL, rankLabel } from "@/lib/hokm/types";
const SIZES = {
sm: { w: 44, h: 62, rank: "text-base", pip: "text-lg", center: "text-2xl" },
md: { w: 60, h: 84, rank: "text-lg", pip: "text-xl", center: "text-3xl" },
lg: { w: 74, h: 104, rank: "text-xl", pip: "text-2xl", center: "text-4xl" },
} as const;
export type CardSize = keyof typeof SIZES;
interface Props {
card?: Card;
faceDown?: boolean;
size?: CardSize;
className?: string;
dimmed?: boolean;
}
export function PlayingCard({
card,
faceDown,
size = "md",
className,
dimmed,
}: Props) {
const s = SIZES[size];
if (faceDown || !card) {
return (
<div
className={cn("card-back rounded-lg shrink-0", className)}
style={{ width: s.w, height: s.h }}
aria-hidden
>
<div className="h-full w-full rounded-lg flex items-center justify-center">
<div className="text-gold-500/70 text-lg font-bold"></div>
</div>
</div>
);
}
const red = SUIT_IS_RED[card.suit];
const color = red ? "text-rose-600" : "text-slate-900";
const symbol = SUIT_SYMBOL[card.suit];
return (
<div
className={cn(
"card-face rounded-lg shrink-0 relative select-none transition-opacity",
dimmed && "opacity-45",
className
)}
style={{ width: s.w, height: s.h }}
>
<div className={cn("absolute top-1 left-1.5 leading-none font-bold", color, s.rank)}>
<div>{rankLabel(card.rank)}</div>
<div className={s.rank}>{symbol}</div>
</div>
<div
className={cn(
"absolute inset-0 flex items-center justify-center font-bold",
color,
s.center
)}
>
{symbol}
</div>
<div
className={cn(
"absolute bottom-1 right-1.5 leading-none font-bold rotate-180",
color,
s.rank
)}
>
<div>{rankLabel(card.rank)}</div>
<div className={s.rank}>{symbol}</div>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Coins } from "lucide-react";
import { useEffect, useState } from "react";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { DAILY_REWARDS } from "@/lib/online/gamification";
import { getService } from "@/lib/online/service";
import { DailyRewardState } from "@/lib/online/types";
import { cn } from "@/lib/cn";
export function DailyRewardModal() {
const open = useUIStore((s) => s.dailyModalOpen);
const close = useUIStore((s) => s.closeDaily);
const refreshProfile = useSessionStore((s) => s.refreshProfile);
const { t } = useI18n();
const [state, setState] = useState<DailyRewardState | null>(null);
const [claimed, setClaimed] = useState<number | null>(null);
useEffect(() => {
if (open) {
setClaimed(null);
getService().getDailyState().then(setState);
}
}, [open]);
const claim = async () => {
const res = await getService().claimDaily();
setClaimed(res.reward);
await refreshProfile();
setState(await getService().getDailyState());
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={close}
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
>
<motion.div
initial={{ scale: 0.9, y: 16 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass rounded-3xl p-7 w-full max-w-sm text-center"
>
<h2 className="gold-text text-2xl font-black">{t("daily.title")}</h2>
<div className="grid grid-cols-4 gap-2 mt-5">
{DAILY_REWARDS.map((coins, i) => {
const day = i + 1;
const isToday = state?.day === day && state?.available;
const isPast = state ? day < state.day : false;
return (
<div
key={day}
className={cn(
"rounded-xl py-2.5 flex flex-col items-center gap-1 border",
i === 6 && "col-span-4 flex-row justify-center gap-3",
isToday
? "btn-gold border-transparent"
: isPast
? "bg-navy-900/50 border-teal-500/30 opacity-60"
: "bg-navy-900/70 gold-border"
)}
>
<span className={cn("text-[10px]", isToday ? "text-[#2a1f04]" : "text-cream/60")}>
{t("daily.day", { n: day })}
</span>
<span
className={cn(
"flex items-center gap-1 text-sm font-bold",
isToday ? "text-[#2a1f04]" : "text-gold-300"
)}
>
{coins}
<Coins className="size-3" />
</span>
</div>
);
})}
</div>
{claimed != null ? (
<p className="mt-5 text-teal-300 font-bold flex items-center justify-center gap-1.5">
+{claimed} <Coins className="size-4 text-gold-400" /> {t("daily.claimed")}
</p>
) : state?.available ? (
<button onClick={claim} className="btn-gold w-full rounded-xl py-3 mt-5">
{t("daily.claim")}
</button>
) : (
<p className="mt-5 text-cream/50 text-sm">{t("daily.come")}</p>
)}
<button
onClick={close}
className="mt-3 text-cream/50 text-sm hover:text-cream/80"
>
{t("common.back")}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,165 @@
"use client";
import { motion } from "framer-motion";
import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react";
import { useI18n } from "@/lib/i18n";
import { RewardResult } from "@/lib/online/types";
export function PostMatchRewardsModal({
reward,
won,
onClose,
}: {
reward: RewardResult;
won: boolean;
onClose: () => void;
}) {
const { t, locale } = useI18n();
const sign = (n: number) => (n > 0 ? `+${n}` : `${n}`);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
>
<motion.div
initial={{ scale: 0.85, y: 24 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
className="glass rounded-3xl p-7 w-full max-w-sm text-center"
>
<motion.div
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 160, delay: 0.1 }}
className="text-5xl mb-2"
>
{won ? "🏆" : "🎴"}
</motion.div>
<h2 className="gold-text text-2xl font-black">{t("reward.title")}</h2>
<p className={"mt-1 font-bold " + (won ? "text-teal-300" : "text-rose-300")}>
{won ? t("reward.win") : t("reward.lose")}
</p>
<div className="mt-5 space-y-2.5">
{reward.ratingDelta !== 0 && (
<RewardRow
icon={
reward.ratingDelta > 0 ? (
<TrendingUp className="size-4 text-teal-300" />
) : (
<TrendingDown className="size-4 text-rose-300" />
)
}
label={t("reward.rating")}
value={sign(reward.ratingDelta)}
positive={reward.ratingDelta > 0}
delay={0.2}
/>
)}
<RewardRow
icon={<Coins className="size-4 text-gold-400" />}
label={t("reward.coins")}
value={sign(reward.coinsDelta)}
positive={reward.coinsDelta >= 0}
delay={0.3}
/>
<RewardRow
icon={<Star className="size-4 text-gold-400" />}
label={t("reward.xp")}
value={`+${reward.xpGained}`}
positive
delay={0.4}
/>
</div>
{reward.leveledUp && (
<Banner delay={0.5} text={`${t("reward.levelUp")}${reward.levelAfter}`} />
)}
{reward.promoted && <Banner delay={0.55} text={t("reward.promoted")} />}
{reward.newAchievements.length > 0 && (
<div className="mt-4 space-y-2">
{reward.newAchievements.map((a, i) => (
<motion.div
key={a.id}
initial={{ opacity: 0, x: locale === "fa" ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 + i * 0.12 }}
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
>
<span className="text-xl">{a.icon}</span>
<span className="flex-1">
<span className="block text-[10px] text-gold-400">
{t("reward.newAchievement")}
</span>
<span className="block text-sm text-cream font-semibold">
{locale === "fa" ? a.nameFa : a.nameEn}
</span>
</span>
<span className="text-xs text-gold-300 flex items-center gap-1">
+{a.coinReward}
<Coins className="size-3" />
</span>
</motion.div>
))}
</div>
)}
<button onClick={onClose} className="btn-gold w-full rounded-xl py-3 mt-6">
{t("reward.continue")}
</button>
</motion.div>
</motion.div>
);
}
function RewardRow({
icon,
label,
value,
positive,
delay,
}: {
icon: React.ReactNode;
label: string;
value: string;
positive: boolean;
delay: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
className="glass rounded-xl px-4 py-2.5 flex items-center justify-between"
>
<span className="flex items-center gap-2 text-cream/80 text-sm">
{icon}
{label}
</span>
<span
className={
"font-black tabular-nums " + (positive ? "text-teal-300" : "text-rose-300")
}
>
{value}
</span>
</motion.div>
);
}
function Banner({ text, delay }: { text: string; delay: number }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay }}
className="mt-4 inline-flex items-center gap-2 btn-gold rounded-full px-5 py-1.5 font-black"
>
<Sparkles className="size-4" />
{text}
</motion.div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Shield } from "lucide-react";
import { divisionLabel, getLeagueInfo } from "@/lib/online/gamification";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
export function RankBadge({
rating,
className,
showRating,
}: {
rating: number;
className?: string;
showRating?: boolean;
}) {
const { locale } = useI18n();
const l = getLeagueInfo(rating);
const name = locale === "fa" ? l.tier.nameFa : l.tier.nameEn;
const div = divisionLabel(l.division);
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold",
className
)}
style={{
color: l.tier.color,
background: `${l.tier.color}1a`,
border: `1px solid ${l.tier.color}55`,
}}
>
<Shield className="size-3.5" style={{ fill: `${l.tier.color}33` }} />
{name}
{div && <span className="opacity-80">{div}</span>}
{showRating && <span className="opacity-70">· {Math.round(rating)}</span>}
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useI18n } from "@/lib/i18n";
import { useUIStore, type Screen } from "@/lib/ui-store";
export function ScreenHeader({
title,
back = "home",
right,
}: {
title: string;
back?: Screen;
right?: React.ReactNode;
}) {
const go = useUIStore((s) => s.go);
const { locale } = useI18n();
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
return (
<div className="flex items-center justify-between gap-3 mb-5">
<button
onClick={() => go(back)}
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
>
<Chevron className="size-5 text-cream/80" />
</button>
<h1 className="gold-text text-2xl font-black">{title}</h1>
<div className="min-w-10 flex justify-end">{right}</div>
</div>
);
}
export function ScreenShell({ children }: { children: React.ReactNode }) {
return (
<main className="persian-pattern relative min-h-dvh w-full overflow-y-auto">
<div className="mx-auto w-full max-w-2xl p-4 sm:p-6">{children}</div>
</main>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { Coins, Gift } from "lucide-react";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { avatarEmoji } from "@/lib/online/types";
export function TopBar() {
const profile = useSessionStore((s) => s.profile);
const go = useUIStore((s) => s.go);
const openDaily = useUIStore((s) => s.openDaily);
const { t } = useI18n();
if (!profile) return null;
return (
<div className="flex items-center justify-between gap-3">
<button
onClick={() => go("profile")}
className="glass rounded-full ltr:pr-4 rtl:pl-4 ltr:pl-1.5 rtl:pr-1.5 py-1.5 flex items-center gap-2 hover:bg-navy-800/80 transition"
>
<span className="size-9 rounded-full bg-navy-900 gold-border flex items-center justify-center text-xl">
{avatarEmoji(profile.avatar)}
</span>
<span className="text-start leading-tight">
<span className="block text-sm font-bold text-cream max-w-24 truncate">
{profile.displayName}
</span>
<span className="block text-[10px] text-gold-400/80">
{t("common.level")} {profile.level}
</span>
</span>
</button>
<div className="flex items-center gap-2">
<button
onClick={openDaily}
className="glass rounded-full p-2 hover:bg-navy-800/80 transition"
title={t("daily.title")}
>
<Gift className="size-4 text-gold-400" />
</button>
<div className="glass rounded-full px-3 py-1.5 flex items-center gap-1.5">
<Coins className="size-4 text-gold-400" />
<span className="text-sm font-bold text-cream tabular-nums">
{profile.coins.toLocaleString()}
</span>
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { xpNeededForLevel } from "@/lib/online/gamification";
import { useI18n } from "@/lib/i18n";
export function XpBar({ level, xp }: { level: number; xp: number }) {
const { t } = useI18n();
const need = xpNeededForLevel(level);
const pct = Math.min(100, Math.round((xp / need) * 100));
return (
<div className="w-full">
<div className="flex items-center justify-between text-[10px] text-cream/55 mb-1">
<span>
{t("common.level")} {level}
</span>
<span className="tabular-nums">
{xp} / {need} XP
</span>
</div>
<div className="h-2.5 rounded-full bg-navy-900/80 overflow-hidden gold-border">
<div
className="h-full rounded-full transition-all"
style={{
width: `${pct}%`,
background: "linear-gradient(90deg, var(--gold-500), var(--gold-300))",
}}
/>
</div>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { motion } from "framer-motion";
import { Mail, Phone } from "lucide-react";
import { useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
type Tab = "phone" | "email";
export function AuthScreen() {
const { t } = useI18n();
const go = useUIStore((s) => s.go);
const s = useSessionStore();
const [tab, setTab] = useState<Tab>("phone");
const done = () => go("online");
return (
<ScreenShell>
<ScreenHeader title={t("auth.title")} />
<div className="glass rounded-3xl p-6 max-w-md mx-auto">
<p className="text-center text-cream/60 text-sm mb-5">{t("auth.subtitle")}</p>
<div className="flex gap-2 p-1 rounded-xl bg-navy-900/70 mb-5">
<TabBtn active={tab === "phone"} onClick={() => setTab("phone")} icon={<Phone className="size-4" />} label={t("auth.phone")} />
<TabBtn active={tab === "email"} onClick={() => setTab("email")} icon={<Mail className="size-4" />} label={t("auth.email")} />
</div>
{tab === "phone" ? <PhoneForm onDone={done} /> : <EmailForm onDone={done} />}
<div className="mt-5 pt-5 border-t border-gold-500/15">
<button
onClick={async () => {
await s.signInGoogle();
done();
}}
className="w-full rounded-xl bg-white text-slate-800 font-bold py-3 flex items-center justify-center gap-2 hover:bg-white/90 transition"
>
<GoogleIcon />
{t("auth.google")}
</button>
</div>
</div>
</ScreenShell>
);
}
function TabBtn({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
onClick={onClick}
className={cn(
"flex-1 rounded-lg py-2 text-sm font-bold flex items-center justify-center gap-1.5 transition",
active ? "btn-gold" : "text-cream/60 hover:text-cream"
)}
>
{icon}
{label}
</button>
);
}
function PhoneForm({ onDone }: { onDone: () => void }) {
const { t } = useI18n();
const requestOtp = useSessionStore((s) => s.requestOtp);
const verifyOtp = useSessionStore((s) => s.verifyOtp);
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState("");
const send = async () => {
if (phone.trim().length < 4) return;
const res = await requestOtp(phone.trim());
setDevCode(res.devCode ?? null);
setError("");
};
const verify = async () => {
try {
await verifyOtp(phone.trim(), code.trim());
onDone();
} catch {
setError(t("auth.invalidCode"));
}
};
return (
<div className="space-y-3">
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.phoneLabel")}</label>
<input
dir="ltr"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder={t("auth.phonePlaceholder")}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream text-center tracking-wider outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
{devCode == null ? (
<button onClick={send} className="btn-gold w-full rounded-xl py-3">
{t("auth.sendCode")}
</button>
) : (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-3">
<div className="text-center text-xs text-gold-300 glass rounded-lg py-1.5">
{t("auth.devCode", { code: devCode })}
</div>
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.codeLabel")}</label>
<input
dir="ltr"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("auth.codePlaceholder")}
maxLength={4}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream text-center text-xl tracking-[0.5em] outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
{error && <p className="text-rose-300 text-sm text-center">{error}</p>}
<button onClick={verify} className="btn-gold w-full rounded-xl py-3">
{t("auth.verify")}
</button>
</motion.div>
)}
</div>
);
}
function EmailForm({ onDone }: { onDone: () => void }) {
const { t } = useI18n();
const signInEmail = useSessionStore((s) => s.signInEmail);
const signUpEmail = useSessionStore((s) => s.signUpEmail);
const [mode, setMode] = useState<"in" | "up">("in");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const submit = async () => {
if (!email.trim() || !password.trim()) return;
if (mode === "in") await signInEmail(email.trim(), password);
else await signUpEmail(email.trim(), password, name.trim());
onDone();
};
return (
<div className="space-y-3">
{mode === "up" && (
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.nameLabel")}</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
)}
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.emailLabel")}</label>
<input
dir="ltr"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
<div>
<label className="block text-xs text-cream/55 mb-1.5">{t("auth.passLabel")}</label>
<input
dir="ltr"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-xl bg-navy-900/70 gold-border px-4 py-3 text-cream outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
<button onClick={submit} className="btn-gold w-full rounded-xl py-3">
{mode === "in" ? t("auth.signIn") : t("auth.signUp")}
</button>
<button
onClick={() => setMode(mode === "in" ? "up" : "in")}
className="w-full text-center text-sm text-cream/55 hover:text-cream"
>
{mode === "in" ? t("auth.toggleSignup") : t("auth.toggleSignin")}
</button>
</div>
);
}
function GoogleIcon() {
return (
<svg className="size-4" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23z" />
<path fill="#FBBC05" d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z" />
</svg>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { Check, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useOnlineStore } from "@/lib/online-store";
import { useI18n } from "@/lib/i18n";
import { Friend, PresenceStatus, avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = {
online: "bg-teal-400",
offline: "bg-slate-500",
"in-game": "bg-gold-400",
};
export function FriendsScreen() {
const { t, locale } = useI18n();
const friends = useOnlineStore((s) => s.friends);
const requests = useOnlineStore((s) => s.requests);
const load = useOnlineStore((s) => s.loadFriends);
const addFriend = useOnlineStore((s) => s.addFriend);
const accept = useOnlineStore((s) => s.acceptRequest);
const decline = useOnlineStore((s) => s.declineRequest);
const remove = useOnlineStore((s) => s.removeFriend);
const [query, setQuery] = useState("");
useEffect(() => {
load();
}, [load]);
const statusLabel = (s: PresenceStatus) =>
s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline");
const add = async () => {
if (!query.trim()) return;
await addFriend(query);
setQuery("");
};
return (
<ScreenShell>
<ScreenHeader title={t("friends.title")} />
{/* add */}
<div className="glass rounded-2xl p-3 flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder={t("friends.addPlaceholder")}
className="flex-1 rounded-xl bg-navy-900/70 gold-border px-3 py-2 text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40"
/>
<button onClick={add} className="btn-gold rounded-xl px-4 flex items-center gap-1.5">
<UserPlus className="size-4" />
{t("friends.add")}
</button>
</div>
{/* requests */}
{requests.length > 0 && (
<div className="mt-4">
<h3 className="text-xs text-cream/55 mb-2">{t("friends.requests")}</h3>
<div className="space-y-2">
{requests.map((r) => (
<div key={r.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<span className="text-2xl">{avatarEmoji(r.from.avatar)}</span>
<span className="flex-1 text-sm font-semibold text-cream">
{r.from.displayName}
</span>
<button
onClick={() => accept(r.id)}
className="size-8 rounded-lg bg-teal-600/80 flex items-center justify-center hover:bg-teal-600"
>
<Check className="size-4 text-white" />
</button>
<button
onClick={() => decline(r.id)}
className="size-8 rounded-lg bg-rose-700/70 flex items-center justify-center hover:bg-rose-700"
>
<X className="size-4 text-white" />
</button>
</div>
))}
</div>
</div>
)}
{/* list */}
<div className="mt-4 space-y-2 pb-6">
{friends.length === 0 && (
<p className="text-center text-cream/40 py-10">{t("friends.empty")}</p>
)}
{friends.map((f: Friend) => (
<div key={f.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<div className="relative">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span
className={cn(
"absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900",
STATUS_COLOR[f.status]
)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate">{f.displayName}</div>
<div className="text-[11px] text-cream/45">
{statusLabel(f.status)} · {t("common.level")} {f.level}
</div>
</div>
<span className="text-[11px] text-gold-300/80">{Math.round(f.rating)}</span>
<button
onClick={() => remove(f.id)}
className="size-8 rounded-lg hover:bg-rose-700/40 flex items-center justify-center text-cream/40 hover:text-rose-300"
title={t("friends.remove")}
>
<X className="size-4" />
</button>
</div>
))}
</div>
<span className="sr-only">{locale}</span>
</ScreenShell>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GameTable } from "@/components/GameTable";
import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { getService } from "@/lib/online/service";
import { MatchSummary, RewardResult } from "@/lib/online/types";
export function GameScreen() {
const game = useGameStore((s) => s.game);
const mode = useGameStore((s) => s.mode);
const tally = useGameStore((s) => s.tally);
const meta = useGameStore((s) => s.matchMeta);
const reset = useGameStore((s) => s.reset);
const returnTo = useUIStore((s) => s.returnTo);
const go = useUIStore((s) => s.go);
const refreshProfile = useSessionStore((s) => s.refreshProfile);
const [reward, setReward] = useState<RewardResult | null>(null);
const submitted = useRef(false);
const exit = () => {
reset();
go(returnTo);
};
useEffect(() => {
if (mode === "online" && game.phase === "match-over" && !submitted.current) {
submitted.current = true;
const summary: MatchSummary = {
ranked: meta.ranked,
stake: meta.stake,
won: game.matchWinner === 0,
kotFor: tally.kotFor,
kotAgainst: tally.kotAgainst,
tricksWon: tally.tricksTeam0,
rounds: game.matchScore[0] + game.matchScore[1],
trump: game.trump,
};
getService()
.submitMatchResult(summary)
.then((r) => {
setReward(r);
refreshProfile();
});
}
}, [mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
return (
<>
<GameTable onExit={exit} />
{reward && (
<PostMatchRewardsModal
reward={reward}
won={game.matchWinner === 0}
onClose={() => {
setReward(null);
exit();
}}
/>
)}
</>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useEffect } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { RankBadge } from "@/components/online/RankBadge";
import { useOnlineStore } from "@/lib/online-store";
import { useI18n } from "@/lib/i18n";
import { avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
const MEDALS: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
export function LeaderboardScreen() {
const { t } = useI18n();
const leaderboard = useOnlineStore((s) => s.leaderboard);
const load = useOnlineStore((s) => s.loadLeaderboard);
useEffect(() => {
load();
}, [load]);
return (
<ScreenShell>
<ScreenHeader title={t("lead.title")} />
<div className="space-y-1.5 pb-6">
{leaderboard.map((e) => (
<div
key={e.id}
className={cn(
"rounded-xl p-2.5 flex items-center gap-3 border",
e.isYou
? "bg-gold-500/15 border-gold-500/50"
: "glass border-transparent"
)}
>
<span className="w-7 text-center font-black text-cream/70 tabular-nums">
{MEDALS[e.rank] ?? e.rank}
</span>
<span className="text-2xl">{avatarEmoji(e.avatar)}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate">
{e.displayName}
{e.isYou && <span className="text-gold-300"> ({t("seat.you")})</span>}
</div>
<div className="text-[10px] text-cream/45">
{t("common.level")} {e.level}
</div>
</div>
<RankBadge rating={e.rating} showRating />
</div>
))}
</div>
</ScreenShell>
);
}
@@ -0,0 +1,122 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Loader2 } from "lucide-react";
import { ScreenShell } from "@/components/online/ScreenHeader";
import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { avatarEmoji } from "@/lib/online/types";
export function MatchmakingScreen() {
const { t } = useI18n();
const mm = useOnlineStore((s) => s.matchmaking);
const cancelMatchmaking = useOnlineStore((s) => s.cancelMatchmaking);
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
const goGame = useUIStore((s) => s.goGame);
const go = useUIStore((s) => s.go);
const ready = mm.phase === "ready";
const slots = [0, 1, 2, 3];
const cancel = async () => {
await cancelMatchmaking();
go("online");
};
const enter = () => {
const players = getService().getMatchPlayers();
if (!players) return;
newOnlineMatch({
players: players.map((p) => ({
displayName: p.displayName,
avatar: p.avatar,
level: p.level,
})),
targetScore: 7,
stake: mm.stake,
ranked: mm.ranked,
});
goGame("home");
};
return (
<ScreenShell>
<div className="flex flex-col items-center justify-center min-h-[80dvh] text-center">
<motion.div
animate={ready ? {} : { rotate: 360 }}
transition={{ repeat: ready ? 0 : Infinity, duration: 2, ease: "linear" }}
className="mb-6"
>
{ready ? (
<span className="text-5xl"></span>
) : (
<Loader2 className="size-12 text-gold-400" />
)}
</motion.div>
<h1 className="gold-text text-2xl font-black">
{ready ? t("mm.ready") : mm.phase === "found" ? t("mm.found") : t("mm.searching")}
</h1>
<div className="grid grid-cols-4 gap-3 mt-8">
{slots.map((i) => {
const p = mm.players[i];
return (
<div
key={i}
className="w-16 h-20 rounded-2xl glass flex flex-col items-center justify-center gap-1"
>
<AnimatePresence mode="wait">
{p ? (
<motion.div
key={p.id}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center gap-0.5"
>
<span className="text-2xl">{avatarEmoji(p.avatar)}</span>
<span className="text-[9px] text-cream/70 max-w-14 truncate">
{p.displayName}
</span>
<span className="text-[8px] text-gold-400/70">
{t("common.level")} {p.level}
</span>
</motion.div>
) : (
<motion.span
key="empty"
className="text-cream/20 text-2xl"
animate={{ opacity: [0.2, 0.5, 0.2] }}
transition={{ repeat: Infinity, duration: 1.4 }}
>
?
</motion.span>
)}
</AnimatePresence>
</div>
);
})}
</div>
<div className="mt-10 flex gap-3">
<button onClick={cancel} className="glass rounded-xl px-6 py-3 text-cream/70 hover:text-cream">
{t("mm.cancel")}
</button>
{ready && (
<motion.button
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={enter}
className="btn-gold rounded-xl px-8 py-3 text-lg"
>
{t("mm.start")}
</motion.button>
)}
</div>
</div>
</ScreenShell>
);
}
@@ -0,0 +1,89 @@
"use client";
import { motion } from "framer-motion";
import { Coins, Trophy, Users } from "lucide-react";
import { useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
const STAKES = [0, 100, 500, 1000];
export function OnlineLobbyScreen() {
const { t } = useI18n();
const createRoom = useOnlineStore((s) => s.createRoom);
const startMatchmaking = useOnlineStore((s) => s.startMatchmaking);
const go = useUIStore((s) => s.go);
const [stake, setStake] = useState(100);
const onCreate = async () => {
await createRoom({ targetScore: 7, stake, ranked: false });
go("room");
};
const onRandom = async () => {
await startMatchmaking({ ranked: true, stake });
go("matchmaking");
};
return (
<ScreenShell>
<ScreenHeader title={t("lobby.title")} />
{/* stake */}
<div className="glass rounded-2xl p-4 mb-4">
<div className="flex items-center gap-1.5 text-sm text-cream/70 mb-2.5">
<Coins className="size-4 text-gold-400" />
{t("room.stake")}
</div>
<div className="flex gap-2">
{STAKES.map((s) => (
<button
key={s}
onClick={() => setStake(s)}
className={cn(
"flex-1 rounded-xl py-2.5 text-sm font-bold transition",
stake === s ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70 hover:text-cream"
)}
>
{s === 0 ? t("menu.guest") : s.toLocaleString()}
</button>
))}
</div>
</div>
<div className="space-y-3">
<motion.button
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
onClick={onRandom}
className="btn-gold w-full rounded-2xl p-5 flex items-center gap-4 text-start"
>
<span className="size-12 rounded-xl bg-black/15 flex items-center justify-center text-[#2a1f04]">
<Trophy className="size-6" />
</span>
<span>
<span className="block text-lg font-black text-[#2a1f04]">{t("lobby.random")}</span>
<span className="block text-xs text-[#2a1f04]/70">{t("lobby.randomDesc")}</span>
</span>
</motion.button>
<motion.button
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
onClick={onCreate}
className="glass w-full rounded-2xl p-5 flex items-center gap-4 text-start hover:bg-navy-800/80 transition"
>
<span className="size-12 rounded-xl bg-navy-900 gold-border flex items-center justify-center text-gold-400">
<Users className="size-6" />
</span>
<span>
<span className="block text-lg font-black text-cream">{t("lobby.createRoom")}</span>
<span className="block text-xs text-cream/55">{t("lobby.createDesc")}</span>
</span>
</motion.button>
</div>
</ScreenShell>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { Check, Coins, Pencil } from "lucide-react";
import { useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { RankBadge } from "@/components/online/RankBadge";
import { XpBar } from "@/components/online/XpBar";
import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
import { ACHIEVEMENTS, achievementProgress } from "@/lib/online/gamification";
import { AVATARS, avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
export function ProfileScreen() {
const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile);
const updateProfile = useSessionStore((s) => s.updateProfile);
const [editing, setEditing] = useState(false);
const [name, setName] = useState(profile?.displayName ?? "");
if (!profile) return null;
const s = profile.stats;
const winrate = s.games > 0 ? Math.round((s.wins / s.games) * 100) : 0;
const saveName = async () => {
if (name.trim()) await updateProfile({ displayName: name.trim() });
setEditing(false);
};
return (
<ScreenShell>
<ScreenHeader title={t("profile.title")} />
{/* identity */}
<div className="glass rounded-3xl p-5 text-center">
<div className="size-20 mx-auto rounded-2xl bg-navy-900 gold-border flex items-center justify-center text-4xl">
{avatarEmoji(profile.avatar)}
</div>
{editing ? (
<div className="mt-3 flex items-center justify-center gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded-lg bg-navy-900/70 gold-border px-3 py-1.5 text-center text-cream outline-none focus:ring-2 focus:ring-gold-500/40"
/>
<button onClick={saveName} className="btn-gold rounded-lg p-2">
<Check className="size-4" />
</button>
</div>
) : (
<button
onClick={() => {
setName(profile.displayName);
setEditing(true);
}}
className="mt-3 inline-flex items-center gap-2 text-xl font-black text-cream hover:text-gold-300 transition"
>
{profile.displayName}
<Pencil className="size-3.5 text-cream/40" />
</button>
)}
<div className="mt-2 flex items-center justify-center gap-2">
<RankBadge rating={profile.rating} showRating />
<span className="glass rounded-full px-2.5 py-1 text-xs font-bold text-gold-300 flex items-center gap-1">
<Coins className="size-3.5 text-gold-400" />
{profile.coins.toLocaleString()}
</span>
</div>
<div className="mt-4">
<XpBar level={profile.level} xp={profile.xp} />
</div>
</div>
{/* avatar picker */}
<div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.chooseAvatar")}</h3>
<div className="flex flex-wrap gap-2">
{AVATARS.filter((a) => profile.ownedAvatars.includes(a.id)).map((a) => (
<button
key={a.id}
onClick={() => updateProfile({ avatar: a.id })}
className={cn(
"size-12 rounded-xl bg-navy-900/70 flex items-center justify-center text-2xl transition",
profile.avatar === a.id ? "gold-border ring-2 ring-gold-400/60" : "border border-transparent hover:bg-navy-800"
)}
>
{a.emoji}
</button>
))}
</div>
</div>
{/* stats */}
<div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.stats")}</h3>
<div className="grid grid-cols-3 gap-2.5">
<Stat label={t("profile.games")} value={s.games} />
<Stat label={t("profile.wins")} value={s.wins} />
<Stat label={t("profile.winrate")} value={`${winrate}%`} />
<Stat label={t("profile.kots")} value={s.kotsFor} />
<Stat label={t("profile.streak")} value={s.bestWinStreak} />
<Stat label={t("common.rating")} value={Math.round(profile.rating)} />
</div>
</div>
{/* achievements */}
<div className="glass rounded-2xl p-4 mt-4 mb-6">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.achievements")}</h3>
<div className="space-y-2">
{ACHIEVEMENTS.map((a) => {
const prog = achievementProgress(a.id, s, profile.rating);
const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal;
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
return (
<div
key={a.id}
className={cn(
"rounded-xl p-3 flex items-center gap-3 border",
unlocked ? "bg-gold-500/10 border-gold-500/40" : "bg-navy-900/50 border-navy-700/50"
)}
>
<span className={cn("text-2xl", !unlocked && "grayscale opacity-50")}>
{a.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream">
{locale === "fa" ? a.nameFa : a.nameEn}
</div>
<div className="text-[11px] text-cream/50 truncate">
{locale === "fa" ? a.descFa : a.descEn}
</div>
{!unlocked && a.goal > 1 && (
<div className="h-1.5 rounded-full bg-navy-900 overflow-hidden mt-1.5">
<div
className="h-full bg-gold-500/70"
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
{unlocked && <Check className="size-4 text-gold-400 shrink-0" />}
</div>
);
})}
</div>
</div>
</ScreenShell>
);
}
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-navy-900/60 rounded-xl py-3 text-center">
<div className="text-xl font-black gold-text">{value}</div>
<div className="text-[10px] text-cream/55 mt-0.5">{label}</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Bot, Copy, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { Friend, RoomSeat, avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
export function RoomScreen() {
const { t } = useI18n();
const room = useOnlineStore((s) => s.room);
const friends = useOnlineStore((s) => s.friends);
const loadFriends = useOnlineStore((s) => s.loadFriends);
const setPartner = useOnlineStore((s) => s.setPartner);
const inviteToSeat = useOnlineStore((s) => s.inviteToSeat);
const addBot = useOnlineStore((s) => s.addBot);
const clearSeat = useOnlineStore((s) => s.clearSeat);
const startRoom = useOnlineStore((s) => s.startRoom);
const leaveRoom = useOnlineStore((s) => s.leaveRoom);
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
const goGame = useUIStore((s) => s.goGame);
const go = useUIStore((s) => s.go);
const [picker, setPicker] = useState<null | { seat: 1 | 2 | 3 }>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
loadFriends();
}, [loadFriends]);
if (!room) return null;
const seat = (n: number) => room.seats.find((s) => s.seat === n)!;
const pick = async (friend: Friend) => {
if (!picker) return;
if (picker.seat === 2) await setPartner(friend.id);
else await inviteToSeat(picker.seat, friend.id);
setPicker(null);
};
const copyCode = async () => {
try {
await navigator.clipboard.writeText(room.code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
const start = async () => {
await startRoom();
const r = useOnlineStore.getState().room!;
const players = r.seats
.slice()
.sort((a, b) => a.seat - b.seat)
.map((s) => ({
displayName: s.player!.displayName,
avatar: s.player!.avatar,
level: s.player!.level,
}));
newOnlineMatch({ players, targetScore: r.targetScore, stake: r.stake, ranked: r.ranked });
goGame("home");
};
const leave = async () => {
await leaveRoom();
go("online");
};
return (
<ScreenShell>
<ScreenHeader
title={t("room.title")}
back="online"
right={
<button
onClick={copyCode}
className="glass rounded-full px-3 py-1.5 text-xs flex items-center gap-1.5 hover:bg-navy-800/80"
>
<Copy className="size-3.5 text-gold-400" />
<span className="tabular-nums tracking-wider">{copied ? t("common.copied") : room.code}</span>
</button>
}
/>
{/* your team */}
<h3 className="text-xs text-teal-300 font-bold mb-2">{t("team.us")}</h3>
<div className="grid grid-cols-2 gap-3">
<SeatCard seat={seat(0)} role="you" onInvite={() => {}} onBot={() => {}} onClear={() => {}} />
<SeatCard
seat={seat(2)}
role="partner"
onInvite={() => setPicker({ seat: 2 })}
onBot={() => addBot(2)}
onClear={() => clearSeat(2)}
/>
</div>
{/* opponents */}
<h3 className="text-xs text-rose-300 font-bold mt-5 mb-2">{t("room.opponents")}</h3>
<div className="grid grid-cols-2 gap-3">
<SeatCard
seat={seat(1)}
role="opp"
onInvite={() => setPicker({ seat: 1 })}
onBot={() => addBot(1)}
onClear={() => clearSeat(1)}
/>
<SeatCard
seat={seat(3)}
role="opp"
onInvite={() => setPicker({ seat: 3 })}
onBot={() => addBot(3)}
onClear={() => clearSeat(3)}
/>
</div>
<div className="flex gap-3 mt-7">
<button onClick={leave} className="glass rounded-xl px-5 py-3 text-cream/70 hover:text-cream">
{t("room.leave")}
</button>
<button onClick={start} className="btn-gold flex-1 rounded-xl py-3 text-lg">
{t("room.start")}
</button>
</div>
{/* friend picker */}
<AnimatePresence>
{picker && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setPicker(null)}
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-navy-950/80 backdrop-blur-sm p-4"
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass rounded-3xl p-5 w-full max-w-sm max-h-[70vh] overflow-y-auto"
>
<h3 className="text-lg font-black gold-text mb-3">{t("room.pickFriend")}</h3>
<div className="space-y-2">
{friends.map((f) => (
<button
key={f.id}
onClick={() => pick(f)}
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
>
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span className="flex-1 text-sm font-semibold text-cream">{f.displayName}</span>
<span className="text-[11px] text-cream/45">
{t("common.level")} {f.level}
</span>
</button>
))}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</ScreenShell>
);
}
function SeatCard({
seat,
role,
onInvite,
onBot,
onClear,
}: {
seat: RoomSeat;
role: "you" | "partner" | "opp";
onInvite: () => void;
onBot: () => void;
onClear: () => void;
}) {
const { t } = useI18n();
const filled = seat.kind !== "empty";
const label =
role === "you" ? t("seat.you") : role === "partner" ? t("room.partner") : t("room.opponents");
return (
<div
className={cn(
"rounded-2xl p-4 min-h-32 flex flex-col items-center justify-center gap-2 border",
role === "opp" ? "border-rose-500/25 bg-rose-950/20" : "border-teal-500/25 bg-teal-950/20"
)}
>
<span className="text-[10px] text-cream/50">{label}</span>
{filled ? (
<>
<span className="text-3xl">{avatarEmoji(seat.player?.avatar ?? "a-fox")}</span>
<span className="text-sm font-bold text-cream text-center max-w-full truncate">
{seat.player?.displayName}
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
</span>
{seat.kind === "invited" ? (
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
) : (
role !== "you" && (
<button onClick={onClear} className="text-[10px] text-rose-300/70 hover:text-rose-300 flex items-center gap-1">
<X className="size-3" />
</button>
)
)}
</>
) : (
<div className="flex flex-col gap-1.5 w-full">
<button
onClick={onInvite}
className="rounded-lg bg-navy-900/70 gold-border py-1.5 text-xs text-cream/80 hover:bg-navy-800 flex items-center justify-center gap-1.5"
>
<UserPlus className="size-3.5" />
{t("room.invite")}
</button>
<button
onClick={onBot}
className="rounded-lg bg-navy-900/70 gold-border py-1.5 text-xs text-cream/80 hover:bg-navy-800 flex items-center justify-center gap-1.5"
>
<Bot className="size-3.5" />
{t("room.addBot")}
</button>
</div>
)}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { Check, Coins } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { ShopItem } from "@/lib/online/types";
import { cn } from "@/lib/cn";
export function ShopScreen() {
const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile);
const setProfile = useSessionStore((s) => s.setProfile);
const [items, setItems] = useState<ShopItem[]>([]);
const [msg, setMsg] = useState("");
useEffect(() => {
getService().getShopItems().then(setItems);
}, []);
if (!profile) return null;
const owns = (item: ShopItem) =>
item.kind === "avatar"
? profile.ownedAvatars.includes(item.id)
: profile.ownedThemes.includes(item.id);
const buy = async (item: ShopItem) => {
const res = await getService().buyItem(item.id);
if (res.ok && res.profile) {
setProfile(res.profile);
} else {
setMsg(locale === "fa" ? res.messageFa : res.messageEn);
setTimeout(() => setMsg(""), 1800);
}
};
const avatars = items.filter((i) => i.kind === "avatar");
const themes = items.filter((i) => i.kind === "theme");
return (
<ScreenShell>
<ScreenHeader
title={t("shop.title")}
right={
<span className="glass rounded-full px-3 py-1.5 text-xs font-bold text-gold-300 flex items-center gap-1">
<Coins className="size-3.5 text-gold-400" />
{profile.coins.toLocaleString()}
</span>
}
/>
{msg && (
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
)}
<Section title={t("shop.avatars")}>
<div className="grid grid-cols-3 gap-3">
{avatars.map((item) => (
<ItemCard key={item.id} item={item} owned={owns(item)} onBuy={() => buy(item)} preview={<span className="text-4xl">{item.preview}</span>} />
))}
</div>
</Section>
<Section title={t("shop.themes")}>
<div className="grid grid-cols-3 gap-3">
{themes.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={owns(item)}
onBuy={() => buy(item)}
preview={
<span
className="size-10 rounded-xl border border-white/20"
style={{ background: item.preview }}
/>
}
/>
))}
</div>
</Section>
</ScreenShell>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-5">
<h3 className="text-sm font-bold text-cream/80 mb-3">{title}</h3>
{children}
</div>
);
}
function ItemCard({
item,
owned,
onBuy,
preview,
}: {
item: ShopItem;
owned: boolean;
onBuy: () => void;
preview: React.ReactNode;
}) {
const { t } = useI18n();
return (
<div className="glass rounded-2xl p-3 flex flex-col items-center gap-2">
<div className="h-12 flex items-center justify-center">{preview}</div>
<button
disabled={owned}
onClick={onBuy}
className={cn(
"w-full rounded-lg py-1.5 text-xs font-bold flex items-center justify-center gap-1",
owned ? "bg-navy-900/60 text-teal-300" : "btn-gold"
)}
>
{owned ? (
<>
<Check className="size-3.5" />
{t("shop.owned")}
</>
) : (
<>
<Coins className="size-3.5" />
{item.price.toLocaleString()}
</>
)}
</button>
</div>
);
}