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,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