feat: UNO-style table, social hub, cosmetics, speed mode, store IAB

Game table & play
- UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow,
  big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round
  confetti, match coin-rain.
- Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert;
  mirrored server-side in GameRoom.TurnMs.
- Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing.
- Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint.

Rewards / gifts
- Richer post-match modal (floating coins, XP bar), celebration overlay reveals
  the unlocked sticker pack, boosted daily rewards (client+server synced),
  themed 7-day daily with special day-7.

Social
- Public profile modal (identity, stats, achievement board) from leaderboard /
  friends / discover / end-of-game roster; rate-limited add-friend (10/hour).
- Social hub: Friends / Discover (player search + suggestions) / Messages inbox.
- Profile gender (shown in finder/profile) + social links with public/friends/
  hidden visibility, enforced server-side.

Cosmetics
- Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/
  rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts),
  consistent on table/shop/profile; +Peacock/Rose-Gold backs.
- Purchasable titles (shop Titles section); title shown under the seat on the
  table and in discover/public profile.
- 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods).
- Persistent level+XP bar on Home and every inner screen.

Payments
- Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh.
- Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture,
  Myket native-bridge contract, server-side IabService.Verify for both stores,
  config-driven via Iab__* env. POST /api/coins/iab/verify (JWT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 18:39:24 +03:30
parent e450a6a2ed
commit cb27a16dc1
49 changed files with 3438 additions and 592 deletions
+68 -4
View File
@@ -6,6 +6,7 @@ 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 { isStoreBilling, purchaseViaStore } from "@/lib/storeBilling";
import { sound } from "@/lib/sound";
import { CoinPack } from "@/lib/online/types";
import { cn } from "@/lib/cn";
@@ -14,31 +15,90 @@ export function BuyCoinsScreen() {
const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile);
const setProfile = useSessionStore((s) => s.setProfile);
const refreshProfile = useSessionStore((s) => s.refreshProfile);
const [packs, setPacks] = useState<CoinPack[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [gained, setGained] = useState<number | null>(null);
const [msg, setMsg] = useState("");
useEffect(() => {
getService().getCoinPacks().then(setPacks);
}, []);
// When the user returns from the payment tab, pull the (possibly credited) balance.
useEffect(() => {
const onFocus = () => refreshProfile();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [refreshProfile]);
const fmt = (n: number) =>
new Intl.NumberFormat(locale === "fa" ? "fa-IR" : "en-US").format(n);
const buy = async (p: CoinPack) => {
setBusy(p.id);
const res = await getService().buyCoins(p.id);
// Live: redirect to the ZarinPal gateway; we credit on return via callback.
if (res.redirectUrl) {
window.location.href = res.redirectUrl;
setMsg("");
// Inside a store build (Cafe Bazaar / Myket), route through store billing.
if (isStoreBilling()) {
try {
const r = await purchaseViaStore(p);
if (r.kind === "redirect") return; // Bazaar navigated away; credited on return
if (r.kind === "token") {
const v = await getService().verifyIab(r.store, r.productId, r.token);
if (v.ok && v.profile) {
setProfile(v.profile);
sound.play("purchase");
setGained(v.coins);
setTimeout(() => setGained(null), 2500);
} else {
setMsg(t("buy.failed"));
}
setBusy(null);
return;
}
// unavailable → fall through to the web gateway below
} catch {
setBusy(null);
setMsg(t("buy.failed"));
return;
}
}
let res;
try {
res = await getService().buyCoins(p.id);
} catch {
setBusy(null);
setMsg(t("buy.failed"));
return;
}
// Live: hand off to the ZarinPal gateway. Open it in a NEW tab so the app
// itself never navigates away (and so a slow/blocked gateway can't dead-end
// the whole app). Credit lands via the server callback; we refresh on focus.
if (res.redirectUrl) {
const url = res.redirectUrl;
if (!/^https?:\/\//i.test(url)) {
setBusy(null);
setMsg(t("buy.failed"));
return;
}
const win = window.open(url, "_blank", "noopener,noreferrer");
if (!win) window.location.href = url; // popup blocked → same-tab fallback
setBusy(null);
setMsg(t("buy.redirecting"));
return;
}
// Mock/offline: instant credit.
if (res.ok && res.profile) {
setProfile(res.profile);
sound.play("purchase");
setGained(res.coins);
setTimeout(() => setGained(null), 2500);
} else {
setMsg(t("buy.failed"));
}
setBusy(null);
};
@@ -64,6 +124,10 @@ export function BuyCoinsScreen() {
</div>
)}
{msg && (
<div className="mb-4 text-center text-cream/80 text-sm glass rounded-xl py-2">{msg}</div>
)}
<div className="grid grid-cols-2 gap-3 pb-6">
{packs.map((p) => (
<button
+30 -15
View File
@@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight, Send } from "lucide-react";
import { ChevronLeft, ChevronRight, MessageCircle, Send } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useOnlineStore } from "@/lib/online-store";
import { useSessionStore } from "@/lib/session-store";
@@ -18,6 +18,7 @@ export function ChatScreen() {
const closeChat = useOnlineStore((s) => s.closeChat);
const isPro = useSessionStore((s) => s.profile?.plan === "pro");
const navBack = useUIStore((s) => s.back);
const viewProfile = useUIStore((s) => s.viewProfile);
const [text, setText] = useState("");
const endRef = useRef<HTMLDivElement>(null);
const prevLen = useRef(0);
@@ -51,27 +52,41 @@ export function ChatScreen() {
return (
<main className="persian-pattern relative h-dvh w-full flex flex-col">
{/* header */}
<header className="glass flex items-center gap-3 p-3 shrink-0 z-10">
<button onClick={back} className="rounded-full p-2 hover:bg-navy-800/80 transition">
<header className="glass flex items-center gap-2 p-3 shrink-0 z-10 safe-top">
<button
onClick={back}
className="tap grid place-items-center rounded-full hover:bg-navy-800/80 transition"
aria-label={t("common.back")}
>
<Chevron className="size-5 text-cream/80" />
</button>
<span className="text-2xl">{avatarEmoji(friend.avatar)}</span>
<div className="min-w-0">
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
<div className="text-[11px] text-teal-300">
{friend.status === "online"
? t("friends.online")
: friend.status === "in-game"
? t("friends.inGame")
: t("friends.offline")}
<button
onClick={() => viewProfile(friend.id)}
className="flex items-center gap-3 min-w-0 flex-1 text-start active:scale-[0.99] transition"
>
<span className="text-2xl shrink-0">{avatarEmoji(friend.avatar)}</span>
<div className="min-w-0">
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
<div className="text-[11px] text-teal-300">
{friend.status === "online"
? t("friends.online")
: friend.status === "in-game"
? t("friends.inGame")
: t("friends.offline")}
</div>
</div>
</div>
</button>
</header>
{/* messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.length === 0 && (
<p className="text-center text-cream/40 mt-16">{t("chat.empty")}</p>
<div className="flex flex-col items-center text-center mt-20 gap-3">
<span className="grid size-16 place-items-center rounded-2xl bg-navy-900/60 gold-border">
<MessageCircle className="size-7 text-gold-400/70" />
</span>
<p className="text-cream/45 text-sm">{t("chat.empty")}</p>
</div>
)}
{messages.map((m) => (
<div
@@ -100,7 +115,7 @@ export function ChatScreen() {
/>
<button
onClick={send}
className="btn-gold rounded-full p-3 shrink-0"
className="btn-gold tap grid place-items-center rounded-full shrink-0"
aria-label={t("chat.send")}
>
<Send className="size-4 rtl:-scale-x-100" />
+331 -100
View File
@@ -1,12 +1,34 @@
"use client";
import { Check, MessageCircle, UserMinus, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Check,
Clock,
Loader2,
MessageCircle,
Search,
Sparkles,
UserMinus,
UserPlus,
Users,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { Avatar } from "@/components/online/Avatar";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { Friend, PresenceStatus, avatarEmoji } from "@/lib/online/types";
import { getService } from "@/lib/online/service";
import {
Conversation,
Friend,
PlayerSummary,
PresenceStatus,
avatarEmoji,
} from "@/lib/online/types";
import { GENDER_META } from "@/lib/social";
import { titleById } from "@/lib/online/gamification";
import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = {
@@ -15,74 +37,103 @@ const STATUS_COLOR: Record<PresenceStatus, string> = {
"in-game": "bg-gold-400",
};
type Tab = "friends" | "discover" | "messages";
export function FriendsScreen() {
const { t, locale } = useI18n();
const friends = useOnlineStore((s) => s.friends);
const { t } = useI18n();
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 openChat = useOnlineStore((s) => s.openChat);
const go = useUIStore((s) => s.go);
const [query, setQuery] = useState("");
const [confirmId, setConfirmId] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("friends");
useEffect(() => {
load();
}, [load]);
return (
<ScreenShell>
<ScreenHeader title={t("social.title")} />
{/* tabs */}
<div className="glass rounded-2xl p-1 flex gap-1 mb-4">
<TabButton active={tab === "friends"} onClick={() => setTab("friends")} icon={<Users className="size-4" />} label={t("social.tabFriends")} badge={requests.length} />
<TabButton active={tab === "discover"} onClick={() => setTab("discover")} icon={<Search className="size-4" />} label={t("social.tabDiscover")} />
<TabButton active={tab === "messages"} onClick={() => setTab("messages")} icon={<MessageCircle className="size-4" />} label={t("social.tabMessages")} />
</div>
<AnimatePresence mode="wait">
<motion.div
key={tab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
{tab === "friends" && <FriendsTab />}
{tab === "discover" && <DiscoverTab />}
{tab === "messages" && <MessagesTab />}
</motion.div>
</AnimatePresence>
</ScreenShell>
);
}
function TabButton({
active, onClick, icon, label, badge,
}: {
active: boolean; onClick: () => void; icon: React.ReactNode; label: string; badge?: number;
}) {
return (
<button
onClick={onClick}
className={cn(
"relative flex-1 rounded-xl py-2 text-xs font-bold transition flex items-center justify-center gap-1.5",
active ? "btn-gold" : "text-cream/60 hover:text-cream"
)}
>
{icon}
<span className="truncate">{label}</span>
{!!badge && badge > 0 && (
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white grid place-items-center">
{badge > 9 ? "9+" : badge}
</span>
)}
</button>
);
}
/* ------------------------------ Friends tab ------------------------------ */
function FriendsTab() {
const { t } = useI18n();
const friends = useOnlineStore((s) => s.friends);
const requests = useOnlineStore((s) => s.requests);
const accept = useOnlineStore((s) => s.acceptRequest);
const decline = useOnlineStore((s) => s.declineRequest);
const remove = useOnlineStore((s) => s.removeFriend);
const openChat = useOnlineStore((s) => s.openChat);
const go = useUIStore((s) => s.go);
const viewProfile = useUIStore((s) => s.viewProfile);
const [confirmId, setConfirmId] = useState<string | null>(null);
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">
<div className="mb-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"
>
<button onClick={() => viewProfile(r.from.id)} className="text-2xl active:scale-95 transition">
{avatarEmoji(r.from.avatar)}
</button>
<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 grid place-items-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"
>
<button onClick={() => decline(r.id)} className="size-8 rounded-lg bg-rose-700/70 grid place-items-center hover:bg-rose-700">
<X className="size-4 text-white" />
</button>
</div>
@@ -91,67 +142,36 @@ export function FriendsScreen() {
</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>
)}
<div className="space-y-2 pb-6">
{friends.length === 0 && <EmptyState icon={<Users className="size-7 text-gold-400/70" />} text={t("friends.empty")} />}
{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}
<button onClick={() => viewProfile(f.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
<div className="relative shrink-0">
<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>
<span className="text-[11px] text-gold-300/80">{Math.round(f.rating)}</span>
<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>
</button>
{confirmId === f.id ? (
<>
<span className="text-[11px] text-cream/70">{t("friends.removeQ")}</span>
<button
onClick={() => {
remove(f.id);
setConfirmId(null);
}}
className="size-8 rounded-lg bg-rose-700/70 flex items-center justify-center hover:bg-rose-700"
title={t("common.yes")}
>
<button onClick={() => { remove(f.id); setConfirmId(null); }} className="size-8 rounded-lg bg-rose-700/70 grid place-items-center hover:bg-rose-700">
<Check className="size-4 text-white" />
</button>
<button
onClick={() => setConfirmId(null)}
className="size-8 rounded-lg bg-navy-700/70 flex items-center justify-center hover:bg-navy-700"
title={t("common.no")}
>
<button onClick={() => setConfirmId(null)} className="size-8 rounded-lg bg-navy-700/70 grid place-items-center hover:bg-navy-700">
<X className="size-4 text-cream/80" />
</button>
</>
) : (
<>
<button
onClick={async () => {
await openChat(f);
go("chat");
}}
className="size-8 rounded-lg hover:bg-teal-700/40 flex items-center justify-center text-teal-300/80 hover:text-teal-200"
title={t("friends.message")}
>
<button onClick={async () => { await openChat(f); go("chat"); }} className="size-8 rounded-lg hover:bg-teal-700/40 grid place-items-center text-teal-300/80 hover:text-teal-200" title={t("friends.message")}>
<MessageCircle className="size-4" />
</button>
<button
onClick={() => setConfirmId(f.id)}
className="size-8 rounded-lg hover:bg-navy-800 flex items-center justify-center text-cream/35 hover:text-cream/70"
title={t("friends.remove")}
>
<button onClick={() => setConfirmId(f.id)} className="size-8 rounded-lg hover:bg-navy-800 grid place-items-center text-cream/35 hover:text-cream/70" title={t("friends.remove")}>
<UserMinus className="size-4" />
</button>
</>
@@ -159,7 +179,218 @@ export function FriendsScreen() {
</div>
))}
</div>
<span className="sr-only">{locale}</span>
</ScreenShell>
</>
);
}
/* ------------------------------ Discover tab ----------------------------- */
function DiscoverTab() {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [results, setResults] = useState<PlayerSummary[] | null>(null);
const [suggested, setSuggested] = useState<PlayerSummary[] | null>(null);
const [loading, setLoading] = useState(false);
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
getService().suggestedPlayers().then(setSuggested).catch(() => setSuggested([]));
}, []);
useEffect(() => {
if (debounce.current) clearTimeout(debounce.current);
const q = query.trim();
if (!q) {
setResults(null);
setLoading(false);
return;
}
setLoading(true);
debounce.current = setTimeout(async () => {
try {
setResults(await getService().searchPlayers(q));
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 350);
return () => { if (debounce.current) clearTimeout(debounce.current); };
}, [query]);
const list = results ?? suggested;
return (
<div className="pb-6">
{/* search */}
<div className="glass rounded-2xl p-2 flex items-center gap-2 mb-4">
<Search className="size-4 text-cream/40 ms-2 shrink-0" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("discover.searchPlaceholder")}
className="flex-1 bg-transparent py-1.5 text-cream placeholder:text-cream/30 outline-none"
/>
{query && (
<button onClick={() => setQuery("")} className="grid size-7 place-items-center rounded-full hover:bg-navy-800/80">
<X className="size-4 text-cream/50" />
</button>
)}
</div>
<h3 className="text-xs text-cream/55 mb-2 flex items-center gap-1.5">
{results ? <Search className="size-3.5" /> : <Sparkles className="size-3.5 text-gold-400" />}
{results ? t("discover.results") : t("discover.suggested")}
</h3>
{loading && <div className="grid place-items-center py-8"><Loader2 className="size-6 text-gold-400 animate-spin" /></div>}
{!loading && list && list.length === 0 && (
<EmptyState icon={<Search className="size-7 text-gold-400/70" />} text={t("discover.noResults")} />
)}
<div className="space-y-2">
{!loading && list?.map((p) => <DiscoverRow key={p.id} player={p} />)}
</div>
</div>
);
}
function DiscoverRow({ player }: { player: PlayerSummary }) {
const { t, locale } = useI18n();
const viewProfile = useUIStore((s) => s.viewProfile);
const refreshFriends = useOnlineStore((s) => s.loadFriends);
const [state, setState] = useState<"idle" | "sending" | "sent" | "friend" | "error">(
player.isFriend ? "friend" : player.requestSent ? "sent" : "idle"
);
const [err, setErr] = useState("");
const add = async () => {
setState("sending");
const res = await getService().addFriendById(player.id);
if (res.ok) {
setState("sent");
refreshFriends();
} else {
setErr(locale === "fa" ? res.messageFa : res.messageEn);
setState("error");
}
};
return (
<div className="glass rounded-xl p-2.5 flex items-center gap-3">
<button onClick={() => viewProfile(player.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
<div className="relative shrink-0 size-10 rounded-xl bg-navy-900 gold-border grid place-items-center overflow-hidden">
<Avatar id={player.avatar} image={player.avatarImage} size={player.avatarImage ? 40 : 26} />
<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[player.status])} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate flex items-center gap-1.5">
{player.displayName}
{player.gender && GENDER_META[player.gender] && (
<span className="text-xs" style={{ color: GENDER_META[player.gender].color }}>
{GENDER_META[player.gender].symbol}
</span>
)}
</div>
{(() => {
const td = titleById(player.title);
return td ? (
<div className="text-[10px] font-bold gold-text leading-tight truncate">
{locale === "fa" ? td.nameFa : td.nameEn}
</div>
) : null;
})()}
<div className="text-[11px] text-cream/45">{t("common.level")} {player.level} · {Math.round(player.rating)}</div>
</div>
</button>
{state === "friend" ? (
<span className="text-[11px] text-teal-300 flex items-center gap-1 shrink-0 pe-1"><Check className="size-3.5" />{t("discover.friend")}</span>
) : state === "sent" ? (
<span className="text-[11px] text-gold-300 flex items-center gap-1 shrink-0 pe-1"><Clock className="size-3.5" />{t("profile.requestSent")}</span>
) : (
<button
onClick={add}
disabled={state === "sending"}
title={err}
className={cn(
"press-3d rounded-lg px-2.5 py-1.5 text-[11px] font-bold flex items-center gap-1 shrink-0",
state === "error" ? "bg-rose-500/80 text-white" : "btn-gold"
)}
>
{state === "sending" ? <Loader2 className="size-3.5 animate-spin" /> : <UserPlus className="size-3.5" />}
{state === "error" ? t("common.retry") : t("friends.add")}
</button>
)}
</div>
);
}
/* ------------------------------ Messages tab ----------------------------- */
function MessagesTab() {
const { t } = useI18n();
const openChat = useOnlineStore((s) => s.openChat);
const go = useUIStore((s) => s.go);
const [convs, setConvs] = useState<Conversation[] | null>(null);
useEffect(() => {
getService().listConversations().then(setConvs).catch(() => setConvs([]));
}, []);
const open = async (c: Conversation) => {
await openChat(c.friend);
go("chat");
};
const timeAgo = (ts: number) => {
const mins = Math.max(0, Math.floor((Date.now() - ts) / 60000));
if (mins < 1) return t("time.now");
if (mins < 60) return t("time.min", { n: mins });
const hrs = Math.floor(mins / 60);
if (hrs < 24) return t("time.hour", { n: hrs });
return t("time.day", { n: Math.floor(hrs / 24) });
};
if (convs == null) return <div className="grid place-items-center py-10"><Loader2 className="size-6 text-gold-400 animate-spin" /></div>;
return (
<div className="space-y-2 pb-6">
{convs.length === 0 && <EmptyState icon={<MessageCircle className="size-7 text-gold-400/70" />} text={t("messages.empty")} />}
{convs.map((c) => (
<button key={c.friend.id} onClick={() => open(c)} className="w-full glass rounded-xl p-2.5 flex items-center gap-3 text-start active:scale-[0.99] transition">
<div className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(c.friend.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[c.friend.status])} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-cream truncate">{c.friend.displayName}</span>
{c.lastMessage && <span className="text-[10px] text-cream/40 shrink-0">{timeAgo(c.lastMessage.ts)}</span>}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-cream/50 truncate">
{c.lastMessage ? (c.lastMessage.fromMe ? `${t("messages.you")}: ` : "") + c.lastMessage.text : t("chat.empty")}
</span>
{c.unread > 0 && (
<span className="min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white grid place-items-center shrink-0">
{c.unread > 9 ? "9+" : c.unread}
</span>
)}
</div>
</div>
</button>
))}
</div>
);
}
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="flex flex-col items-center text-center py-12 gap-3">
<span className="grid size-16 place-items-center rounded-2xl bg-navy-900/60 gold-border">{icon}</span>
<p className="text-cream/45 text-sm">{text}</p>
</div>
);
}
+27
View File
@@ -10,6 +10,7 @@ import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { pushNotification } from "@/lib/notification-store";
import { celebrate } from "@/lib/celebration-store";
import { MatchSummary, RewardResult } from "@/lib/online/types";
export function GameScreen() {
@@ -66,6 +67,30 @@ export function GameScreen() {
});
};
// Splashy celebration overlay for every achievement / level-up earned in the
// match — fired once the post-match reward summary is dismissed so each unlock
// gets its own animated moment (queued by the celebration store).
const celebrateRewards = (r: RewardResult | null) => {
if (!r) return;
if (r.leveledUp) {
celebrate({
variant: "xp",
icon: "🎚️",
title: t("reward.levelUp"),
levelBefore: r.levelBefore,
levelAfter: r.levelAfter,
});
}
for (const a of r.newAchievements) {
celebrate({
variant: "purchase",
icon: a.icon,
title: t("reward.newAchievement"),
achievements: [a],
});
}
};
// Client-run games (private rooms / casual): submit the result to the server.
useEffect(() => {
if (!live && mode === "online" && game.phase === "match-over" && !submitted.current) {
@@ -149,7 +174,9 @@ export function GameScreen() {
reward={reward}
won={game.matchWinner === 0}
onClose={() => {
const r = reward;
setReward(null);
celebrateRewards(r);
finish();
}}
/>
+23 -3
View File
@@ -5,6 +5,7 @@ import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { RankBadge } from "@/components/online/RankBadge";
import { Avatar } from "@/components/online/Avatar";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
@@ -14,6 +15,7 @@ export function LeaderboardScreen() {
const { t } = useI18n();
const leaderboard = useOnlineStore((s) => s.leaderboard);
const load = useOnlineStore((s) => s.loadLeaderboard);
const viewProfile = useUIStore((s) => s.viewProfile);
useEffect(() => {
load();
@@ -22,12 +24,30 @@ export function LeaderboardScreen() {
return (
<ScreenShell>
<ScreenHeader title={t("lead.title")} />
{leaderboard.length === 0 && (
<div className="space-y-1.5 pb-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="glass rounded-xl p-2.5 flex items-center gap-2.5 animate-pulse">
<span className="size-6 rounded bg-navy-800/80" />
<span className="size-10 rounded-xl bg-navy-800/80 shrink-0" />
<span className="flex-1 space-y-1.5">
<span className="block h-3 w-2/5 rounded bg-navy-800/80" />
<span className="block h-1.5 w-full rounded bg-navy-800/60" />
</span>
<span className="h-5 w-14 rounded-full bg-navy-800/80" />
</div>
))}
</div>
)}
<div className="space-y-1.5 pb-6">
{leaderboard.map((e) => (
<div
<button
key={e.id}
onClick={() => viewProfile(e.id)}
className={cn(
"rounded-xl p-2.5 flex items-center gap-2.5 border",
"w-full text-start rounded-xl p-2.5 flex items-center gap-2.5 border transition hover:brightness-110 active:scale-[0.99]",
e.isYou
? "bg-gold-500/15 border-gold-500/50"
: "glass border-transparent"
@@ -67,7 +87,7 @@ export function LeaderboardScreen() {
</div>
<RankBadge rating={e.rating} showRating />
</div>
</button>
))}
</div>
</ScreenShell>
+20 -1
View File
@@ -2,7 +2,7 @@
import { AnimatePresence, motion } from "framer-motion";
import { Crown, Loader2 } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { ScreenShell } from "@/components/online/ScreenHeader";
import { Avatar } from "@/components/online/Avatar";
import { useGameStore } from "@/lib/game-store";
@@ -24,8 +24,20 @@ export function MatchmakingScreen() {
const ready = mm.phase === "ready";
const queued = mm.phase === "queued";
const searching = mm.phase === "searching";
const slots = [0, 1, 2, 3];
// Elapsed seconds while searching (resets when the search (re)starts).
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!searching) {
setElapsed(0);
return;
}
const id = setInterval(() => setElapsed((s) => s + 1), 1000);
return () => clearInterval(id);
}, [searching]);
// Live server: the server starts the match itself — auto-enter when ready.
useEffect(() => {
if (mm.phase === "ready" && getService().live) {
@@ -107,6 +119,13 @@ export function MatchmakingScreen() {
{ready ? t("mm.ready") : mm.phase === "found" ? t("mm.found") : t("mm.searching")}
</h1>
{searching && (
<>
<div className="mt-2 text-3xl font-black gold-text tabular-nums">{elapsed}s</div>
<p className="text-cream/50 text-xs mt-1 max-w-[16rem]">{t("mm.fillHint")}</p>
</>
)}
<div className="grid grid-cols-4 gap-3 mt-8">
{slots.map((i) => {
const p = mm.players[i];
@@ -1,5 +1,6 @@
"use client";
import { BellOff } from "lucide-react";
import { useEffect } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useNotifStore } from "@/lib/notification-store";
@@ -24,7 +25,12 @@ export function NotificationsScreen() {
<ScreenShell>
<ScreenHeader title={t("notif.title")} />
{items.length === 0 && (
<p className="text-center text-cream/40 py-16">{t("notif.empty")}</p>
<div className="flex flex-col items-center text-center py-16 gap-3">
<span className="grid size-16 place-items-center rounded-2xl bg-navy-900/60 gold-border">
<BellOff className="size-7 text-gold-400/70" />
</span>
<p className="text-cream/45 text-sm">{t("notif.empty")}</p>
</div>
)}
<div className="space-y-2 pb-6">
{items.map((n) => (
+157 -19
View File
@@ -1,6 +1,7 @@
"use client";
import { Check, ChevronLeft, Coins, Crown, Lock, Music, Pencil, Upload, Volume2 } from "lucide-react";
import { motion } from "framer-motion";
import { Check, ChevronLeft, Coins, Crown, Eye, EyeOff, Lock, Music, Pencil, Star, Upload, Users, Volume2 } from "lucide-react";
import { useRef, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { RankBadge } from "@/components/online/RankBadge";
@@ -23,7 +24,9 @@ import {
/** Level required before a player can upload a custom profile photo. */
const PHOTO_UPLOAD_MIN_LEVEL = 25;
import { AVATARS } from "@/lib/online/types";
import { AVATARS, Gender, SocialVisibility } from "@/lib/online/types";
import { GENDER_META, SOCIAL_PLATFORMS } from "@/lib/social";
import { backVisualFromDef, cardBackMotif } from "@/lib/cardBack";
import { cn } from "@/lib/cn";
export function ProfileScreen() {
@@ -64,15 +67,34 @@ export function ProfileScreen() {
<ScreenHeader title={t("profile.title")} />
{/* identity */}
<div className="glass rounded-3xl p-5 text-center">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 22 }}
className="glass rounded-3xl p-5 text-center relative overflow-hidden"
>
{/* soft gold glow behind avatar */}
<div className="pointer-events-none absolute -top-10 left-1/2 -translate-x-1/2 size-40 bg-gold-500/10 blur-3xl rounded-full" />
<div className="relative size-20 mx-auto">
<div className="size-20 rounded-2xl bg-navy-900 gold-border flex items-center justify-center overflow-hidden">
<motion.div
initial={{ scale: 0.8, rotate: -6 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 18, delay: 0.1 }}
className="size-20 rounded-2xl bg-navy-900 gold-border flex items-center justify-center overflow-hidden"
>
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 80 : 56} />
</div>
</motion.div>
{/* level badge */}
<span className="absolute -top-2 ltr:-left-2 rtl:-right-2 rounded-full bg-navy-950 gold-border px-2 py-0.5 text-[10px] font-black text-gold-300 shadow-lg">
{t("common.level")} {profile.level}
</span>
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 320, delay: 0.25 }}
className="absolute -top-2 ltr:-left-2 rtl:-right-2 inline-flex items-center gap-0.5 rounded-full btn-gold px-2 py-0.5 text-[10px] font-black shadow-lg"
>
<Star className="size-2.5" fill="currentColor" />
{profile.level}
</motion.span>
<button
onClick={() => (canUpload ? fileRef.current?.click() : undefined)}
className={cn(
@@ -123,7 +145,7 @@ export function ProfileScreen() {
</div>
<div className="mt-4">
<XpBar level={profile.level} xp={profile.xp} />
<XpBar level={profile.level} xp={profile.xp} showBadge />
</div>
<div className="mt-4">
@@ -142,7 +164,7 @@ export function ProfileScreen() {
</button>
)}
</div>
</div>
</motion.div>
{/* avatar picker */}
<div className="glass rounded-2xl p-4 mt-4">
@@ -227,18 +249,22 @@ export function ProfileScreen() {
)}
>
<span
className="w-7 h-10 rounded-md border"
style={{
borderColor: `${c.accent}80`,
background: `repeating-linear-gradient(45deg, ${c.accent}55 0 4px, transparent 4px 8px), ${c.c2}`,
}}
/>
className="w-7 h-10 rounded-md border grid place-items-center"
style={{ borderColor: `${c.accent}80`, ...backVisualFromDef(c) }}
>
<span className="text-[10px]" style={{ color: `${c.accent}dd` }}>
{cardBackMotif(c.pattern, c.motif)}
</span>
</span>
<span className="text-xs text-cream/80 pe-1">{locale === "fa" ? c.nameFa : c.nameEn}</span>
</button>
))}
</div>
</div>
{/* gender + social links */}
<SocialSettings />
{/* audio settings */}
<SoundSettings />
@@ -281,13 +307,16 @@ export function ProfileScreen() {
return ub - ua;
})
.slice(0, 6)
.map((a) => {
.map((a, idx) => {
const prog = achievementProgress(a, s, profile.rating, profile.level);
const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal;
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
return (
<div
<motion.div
key={a.id}
initial={{ opacity: 0, x: locale === "fa" ? 16 : -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
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"
@@ -313,7 +342,7 @@ export function ProfileScreen() {
)}
</div>
{unlocked && <Check className="size-4 text-gold-400 shrink-0" />}
</div>
</motion.div>
);
})}
</div>
@@ -331,6 +360,115 @@ function Stat({ label, value }: { label: string; value: string | number }) {
);
}
const GENDERS: Gender[] = ["male", "female", "other", ""];
const VIS_OPTIONS: { id: SocialVisibility; icon: React.ReactNode; key: string }[] = [
{ id: "public", icon: <Eye className="size-3.5" />, key: "profile.visPublic" },
{ id: "friends", icon: <Users className="size-3.5" />, key: "profile.visFriends" },
{ id: "hidden", icon: <EyeOff className="size-3.5" />, key: "profile.visHidden" },
];
function SocialSettings() {
const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile);
const updateProfile = useSessionStore((s) => s.updateProfile);
const [links, setLinks] = useState<Record<string, string>>(() => ({
instagram: profile?.socials?.instagram ?? "",
telegram: profile?.socials?.telegram ?? "",
x: profile?.socials?.x ?? "",
youtube: profile?.socials?.youtube ?? "",
}));
const [saved, setSaved] = useState(false);
if (!profile) return null;
const gender = profile.gender ?? "";
const vis = profile.socialsVisibility ?? "public";
const saveLinks = async () => {
const socials = Object.fromEntries(
Object.entries(links).map(([k, v]) => [k, v.trim()]).filter(([, v]) => v)
);
await updateProfile({ socials });
setSaved(true);
setTimeout(() => setSaved(false), 1800);
};
return (
<div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.social")}</h3>
{/* gender */}
<div className="text-xs text-cream/55 mb-2">{t("profile.gender")}</div>
<div className="flex flex-wrap gap-2 mb-4">
{GENDERS.map((g) => {
const meta = g ? GENDER_META[g] : null;
const active = gender === g;
return (
<button
key={g || "none"}
onClick={() => updateProfile({ gender: g })}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-semibold transition flex items-center gap-1.5",
active ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70 hover:text-cream"
)}
>
{meta ? (
<>
<span style={{ color: active ? undefined : meta.color }}>{meta.symbol}</span>
{locale === "fa" ? meta.faLabel : meta.enLabel}
</>
) : (
t("profile.genderNone")
)}
</button>
);
})}
</div>
{/* social links */}
<div className="text-xs text-cream/55 mb-2">{t("profile.socialLinks")}</div>
<div className="space-y-2">
{SOCIAL_PLATFORMS.map((p) => (
<div key={p.key} className="flex items-center gap-2">
<span className="grid size-8 place-items-center rounded-lg bg-navy-900/70 text-base shrink-0" style={{ color: p.color }}>
{p.icon}
</span>
<input
value={links[p.key]}
onChange={(e) => setLinks((l) => ({ ...l, [p.key]: e.target.value }))}
placeholder={p.label}
dir="ltr"
className="flex-1 rounded-lg bg-navy-900/70 gold-border px-3 py-1.5 text-sm text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40"
/>
</div>
))}
</div>
{/* visibility */}
<div className="text-xs text-cream/55 mt-4 mb-2">{t("profile.socialsVisibility")}</div>
<div className="glass rounded-xl p-1 flex gap-1">
{VIS_OPTIONS.map((o) => (
<button
key={o.id}
onClick={() => updateProfile({ socialsVisibility: o.id })}
className={cn(
"flex-1 rounded-lg py-1.5 text-xs font-bold transition flex items-center justify-center gap-1",
vis === o.id ? "btn-gold" : "text-cream/60 hover:text-cream"
)}
>
{o.icon}
{t(o.key)}
</button>
))}
</div>
<p className="text-[10px] text-cream/40 mt-1.5">{t("profile.socialsHint")}</p>
<button onClick={saveLinks} className="press-3d btn-gold w-full rounded-xl py-2.5 mt-3 text-sm font-bold flex items-center justify-center gap-1.5">
{saved ? <><Check className="size-4" />{t("profile.saved")}</> : t("profile.saveLinks")}
</button>
</div>
);
}
function SoundSettings() {
const { t } = useI18n();
const { sfx, music, toggleSfx, toggleMusic } = useSoundStore();
+11 -7
View File
@@ -208,26 +208,30 @@ function SeatCard({
<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
onClick={onClear}
aria-label={t("friends.remove")}
className="grid place-items-center min-h-9 min-w-9 rounded-full text-rose-300/70 hover:text-rose-300 hover:bg-rose-500/10 transition"
>
<X className="size-4" />
</button>
)
)}
</>
) : (
<div className="flex flex-col gap-1.5 w-full">
<div className="flex flex-col gap-2 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"
className="rounded-xl bg-navy-900/70 gold-border py-2.5 text-xs text-cream/80 hover:bg-navy-800 active:scale-[0.98] transition flex items-center justify-center gap-1.5"
>
<UserPlus className="size-3.5" />
<UserPlus className="size-4" />
{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"
className="rounded-xl bg-navy-900/70 gold-border py-2.5 text-xs text-cream/80 hover:bg-navy-800 active:scale-[0.98] transition flex items-center justify-center gap-1.5"
>
<Bot className="size-3.5" />
<Bot className="size-4" />
{t("room.addBot")}
</button>
</div>
+54 -8
View File
@@ -9,7 +9,8 @@ import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { sound } from "@/lib/sound";
import { achievementById } from "@/lib/online/gamification";
import { achievementById, cardBackById } from "@/lib/online/gamification";
import { backVisualFromDef, cardBackMotif } from "@/lib/cardBack";
import { celebrate } from "@/lib/celebration-store";
import { AchievementUnlock, ShopItem } from "@/lib/online/types";
import { cn } from "@/lib/cn";
@@ -34,17 +35,32 @@ function Preview({ item, size }: { item: ShopItem; size: number }) {
</span>
);
case "cardback":
case "cardback": {
const back = cardBackById(item.id);
return (
<span
className="rounded-md border"
className="rounded-md border grid place-items-center"
style={{
width: size * 0.72,
height: size,
borderColor: `${item.preview}80`,
background: `repeating-linear-gradient(45deg, ${item.preview}55 0 4px, transparent 4px 8px), #0a142e`,
borderColor: `${back.accent}80`,
...backVisualFromDef(back),
}}
/>
>
<span style={{ fontSize: size * 0.3, color: `${back.accent}dd`, textShadow: "0 1px 2px rgba(0,0,0,0.4)" }}>
{cardBackMotif(back.pattern, back.motif)}
</span>
</span>
);
}
case "title":
return (
<span
className="rounded-full btn-gold px-2.5 py-1 font-black leading-none whitespace-nowrap"
style={{ fontSize: Math.max(9, size * 0.22) }}
>
🏷
</span>
);
default: // avatar, reactionpack, xp → emoji glyph
return <span style={{ fontSize: size * 0.82, lineHeight: 1 }}>{item.kind === "xp" ? "⚡" : item.preview}</span>;
@@ -71,6 +87,7 @@ export function ShopScreen() {
case "cardfront": return profile.ownedCardFronts.includes(item.id);
case "cardback": return profile.ownedCardBacks.includes(item.id);
case "reactionpack": return profile.ownedReactionPacks.includes(item.id);
case "title": return profile.ownedTitles.includes(item.id);
case "xp": return false; // consumable — always buyable
default: return profile.ownedStickerPacks.includes(item.id);
}
@@ -123,6 +140,7 @@ export function ShopScreen() {
{ title: t("shop.cardbacks"), kind: "cardback" },
{ title: t("shop.reactions"), kind: "reactionpack" },
{ title: t("shop.stickers"), kind: "stickerpack" },
{ title: t("shop.titles"), kind: "title", hint: t("shop.titlesHint") },
{ title: t("shop.xp"), kind: "xp", hint: t("shop.xpHint") },
];
@@ -142,6 +160,25 @@ export function ShopScreen() {
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
)}
{items.length === 0 && (
<div className="space-y-5">
{Array.from({ length: 2 }).map((_, s) => (
<div key={s}>
<div className="h-4 w-24 rounded bg-navy-800/80 animate-pulse mb-3" />
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="glass rounded-2xl p-3 flex flex-col items-center gap-2 animate-pulse">
<div className="size-12 rounded-xl bg-navy-800/80" />
<div className="h-2.5 w-3/4 rounded bg-navy-800/80" />
<div className="h-6 w-full rounded-lg bg-navy-800/60" />
</div>
))}
</div>
</div>
))}
</div>
)}
{sections.map((sec) => {
const list = items.filter((i) => i.kind === sec.kind);
if (!list.length) return null;
@@ -183,14 +220,23 @@ function Section({ title, hint, children }: { title: string; hint?: string; chil
}
function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) {
const { locale } = useI18n();
const { locale, t } = useI18n();
const count = item.contents?.length;
const luxury = item.price >= 2000; // premium "luxury" tier
return (
<motion.button
whileTap={{ scale: 0.96 }}
onClick={onOpen}
className="press-3d glass rounded-2xl p-3 flex flex-col items-center gap-2 relative"
className={cn(
"press-3d glass rounded-2xl p-3 flex flex-col items-center gap-2 relative",
luxury && !owned && "ring-1 ring-gold-400/50"
)}
>
{luxury && !owned && (
<span className="absolute top-1.5 ltr:left-1.5 rtl:right-1.5 inline-flex items-center gap-0.5 rounded-full bg-gradient-to-r from-gold-500 to-gold-300 text-[#2a1f04] text-[8px] font-black px-1.5 py-0.5 shadow">
{t("shop.luxury")}
</span>
)}
{owned && (
<span className="absolute top-1.5 ltr:right-1.5 rtl:left-1.5 grid size-5 place-items-center rounded-full bg-teal-500 text-navy-950">
<Check className="size-3.5" strokeWidth={3} />