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
+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>
);
}