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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user