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