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:
+318
-104
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TURN_MS, useGameStore } from "@/lib/game-store";
|
||||
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff, Zap } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { useSoundStore } from "@/lib/sound-store";
|
||||
import { legalMoves } from "@/lib/hokm/engine";
|
||||
import { sortHand } from "@/lib/hokm/deck";
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@/lib/hokm/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { cardBackById, cardFrontById, ownedReactions, ownedStickers } from "@/lib/online/gamification";
|
||||
import { cardBackById, cardFrontById, ownedReactions, ownedStickers, titleById, turnMsForStake } from "@/lib/online/gamification";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PlayingCard } from "./PlayingCard";
|
||||
@@ -43,7 +43,7 @@ function useCardSkins() {
|
||||
const b = cardBackById(backId);
|
||||
return {
|
||||
front: { bg1: f.bg1, bg2: f.bg2, border: f.border },
|
||||
back: { c1: b.c1, c2: b.c2, accent: b.accent },
|
||||
back: { c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,11 +68,48 @@ export function GameTable({
|
||||
const trickScale = vw < 360 ? 0.5 : vw < 460 ? 0.64 : 1;
|
||||
const { phase, players, hakem, trump, turn, currentTrick } = game;
|
||||
|
||||
const legalIds = new Set(
|
||||
phase === "playing" && turn === 0
|
||||
? legalMoves(game, 0).map((c) => c.id)
|
||||
: []
|
||||
const legalMovesList = useMemo(
|
||||
() => (phase === "playing" && turn === 0 ? legalMoves(game, 0) : []),
|
||||
[phase, turn, game]
|
||||
);
|
||||
const legalIds = new Set(legalMovesList.map((c) => c.id));
|
||||
|
||||
// Keyboard shortcuts (desktop): 1–9 / 0 play the Nth playable card in hand
|
||||
// order, Space/Enter play the first playable card, M mutes, F forfeits,
|
||||
// Esc/Q quits. A floating hint lists them.
|
||||
const playHuman = useGameStore((s) => s.playHuman);
|
||||
const chooseTrump = useGameStore((s) => s.chooseTrump);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
|
||||
// Hakem choosing trump: 1–4 pick a suit.
|
||||
if (phase === "choosing-trump" && players[hakem!]?.isHuman) {
|
||||
const idx = "1234".indexOf(e.key);
|
||||
if (idx >= 0) { e.preventDefault(); chooseTrump(SUITS[idx]); return; }
|
||||
}
|
||||
|
||||
if (phase === "playing" && turn === 0) {
|
||||
const playable = sortHand(game.players[0].hand).filter((c) => legalIds.has(c.id));
|
||||
if (k === " " || k === "enter") {
|
||||
if (playable[0]) { e.preventDefault(); playHuman(playable[0]); }
|
||||
return;
|
||||
}
|
||||
// 1-9 then 0 → 10th
|
||||
const digit = e.key === "0" ? 9 : "123456789".indexOf(e.key);
|
||||
if (digit >= 0 && playable[digit]) { e.preventDefault(); playHuman(playable[digit]); return; }
|
||||
}
|
||||
|
||||
if (k === "m") { e.preventDefault(); toggleAll(); }
|
||||
else if (k === "f" && onForfeit) { e.preventDefault(); setAskFf(true); }
|
||||
else if (k === "escape" || k === "q") { e.preventDefault(); exit(); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, turn, hakem, game.players, legalMovesList]);
|
||||
|
||||
return (
|
||||
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
|
||||
@@ -80,6 +117,7 @@ export function GameTable({
|
||||
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between gap-2 safe-top safe-x pb-3 sm:p-4">
|
||||
<Scoreboard />
|
||||
<div className="flex items-center gap-2">
|
||||
<SpeedBadge />
|
||||
{trump && <TrumpBadge trump={trump} />}
|
||||
<button
|
||||
onClick={toggleAll}
|
||||
@@ -170,6 +208,7 @@ export function GameTable({
|
||||
<TurnTimer />
|
||||
<DisconnectBanner />
|
||||
<Reactions />
|
||||
<ShortcutsHint />
|
||||
|
||||
{/* Overlays */}
|
||||
<AnimatePresence>
|
||||
@@ -237,6 +276,25 @@ function ScoreCol({
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Speed badge ---------------------------- */
|
||||
|
||||
function SpeedBadge() {
|
||||
const speed = useGameStore((s) => s.matchMeta.speed);
|
||||
const { t } = useI18n();
|
||||
if (!speed) return null;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -15 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
className="glass rounded-2xl px-2.5 py-2 flex items-center gap-1 text-gold-300"
|
||||
title={t("speed.label")}
|
||||
>
|
||||
<Zap className="size-4 fill-gold-400 text-gold-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-wide">{t("speed.label")}</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Trump badge ---------------------------- */
|
||||
|
||||
function TrumpBadge({ trump }: { trump: Suit }) {
|
||||
@@ -268,6 +326,7 @@ function TrumpBadge({ trump }: { trump: Suit }) {
|
||||
function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const sp = useGameStore((s) => s.seatPlayers[seat]);
|
||||
const { locale } = useI18n();
|
||||
const player = game.players[seat];
|
||||
const active =
|
||||
(game.phase === "playing" && game.turn === seat) ||
|
||||
@@ -275,30 +334,33 @@ function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
|
||||
const isHakem = game.hakem === seat;
|
||||
const team = teamOf(seat);
|
||||
const name = sp?.name ?? player.name;
|
||||
const titleDef = titleById(sp?.title);
|
||||
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
|
||||
|
||||
return (
|
||||
<div className={cn("z-20 flex flex-col items-center gap-1", className)}>
|
||||
<motion.div
|
||||
animate={
|
||||
active
|
||||
? { boxShadow: "0 0 0 3px rgba(212,175,55,0.9), 0 0 24px rgba(212,175,55,0.5)" }
|
||||
: { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" }
|
||||
}
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-10 sm:size-12 rounded-full flex items-center justify-center font-bold text-lg sm:text-xl",
|
||||
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100"
|
||||
"relative size-12 sm:size-14 rounded-full flex items-center justify-center font-bold text-xl sm:text-2xl transition-all",
|
||||
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100",
|
||||
active && "active-player-ring"
|
||||
)}
|
||||
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
|
||||
>
|
||||
{sp?.avatar ?? name.charAt(0)}
|
||||
{isHakem && (
|
||||
<Crown className="absolute -top-3 size-4 text-gold-400 fill-gold-500" />
|
||||
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
|
||||
)}
|
||||
</motion.div>
|
||||
{active && (
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 size-2.5 rounded-full bg-gold-400 ring-2 ring-navy-900" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-cream max-w-20 truncate hud-shadow">{name}</span>
|
||||
{titleName && (
|
||||
<span className="text-[9px] font-bold gold-text leading-none max-w-24 truncate hud-shadow">{titleName}</span>
|
||||
)}
|
||||
{sp && sp.level > 0 && (
|
||||
<span className="text-[9px] text-gold-300 leading-none hud-shadow">
|
||||
{`Lv ${sp.level}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-gold-300/80 leading-none hud-shadow">{`Lv ${sp.level}`}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -318,22 +380,26 @@ function OpponentHand({
|
||||
const count = useGameStore((s) => s.game.players[seat].hand.length);
|
||||
const { back } = useCardSkins();
|
||||
const cards = Array.from({ length: count });
|
||||
const mid = (count - 1) / 2;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
horizontal ? "flex-row" : "flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{cards.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
|
||||
>
|
||||
<PlayingCard faceDown size="sm" back={back} />
|
||||
</div>
|
||||
))}
|
||||
<div className={cn("flex", horizontal ? "flex-row items-end" : "flex-col items-center", className)}>
|
||||
{cards.map((_, i) => {
|
||||
const rot = horizontal ? (i - mid) * 4 : 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
...(horizontal
|
||||
? { marginInlineStart: i === 0 ? 0 : -30 }
|
||||
: { marginTop: i === 0 ? 0 : -46 }),
|
||||
transform: rot ? `rotate(${rot}deg)` : undefined,
|
||||
transformOrigin: "bottom center",
|
||||
}}
|
||||
>
|
||||
<PlayingCard faceDown size="sm" back={back} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -367,29 +433,23 @@ function TrickArea({
|
||||
const { front } = useCardSkins();
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative size-1 ">
|
||||
<div className="relative size-1">
|
||||
<AnimatePresence>
|
||||
{trick.map((pc) => {
|
||||
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
|
||||
const enter = TRICK_ENTER[pc.seat];
|
||||
const isWinner =
|
||||
phase === "trick-complete" && winner === pc.seat;
|
||||
const isWinner = phase === "trick-complete" && winner === pc.seat;
|
||||
return (
|
||||
<motion.div
|
||||
key={pc.card.id}
|
||||
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
|
||||
animate={{
|
||||
x: off.x,
|
||||
y: off.y,
|
||||
opacity: 1,
|
||||
scale: isWinner ? 1.12 : 1,
|
||||
}}
|
||||
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }}
|
||||
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 26 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
filter: isWinner
|
||||
? "drop-shadow(0 0 14px rgba(212,175,55,0.9))"
|
||||
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
@@ -398,11 +458,55 @@ function TrickArea({
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
{/* Burst particles when trick is won */}
|
||||
<AnimatePresence>
|
||||
{phase === "trick-complete" && winner != null && (
|
||||
<TrickBurst key={`burst-${winner}`} seat={winner} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* particles that fly out from center when you win a trick */
|
||||
const BURST_ANGLES = Array.from({ length: 10 }, (_, i) => {
|
||||
const a = (i / 10) * 2 * Math.PI;
|
||||
const d = 55 + (i % 3) * 22;
|
||||
return { id: i, x: Math.cos(a) * d, y: Math.sin(a) * d, size: 7 + (i % 3) * 5 };
|
||||
});
|
||||
|
||||
function TrickBurst({ seat }: { seat: Seat }) {
|
||||
const team = teamOf(seat);
|
||||
const gradient = team === 0
|
||||
? "radial-gradient(circle,#2dd4bf,#0d9488)"
|
||||
: "radial-gradient(circle,#fb7185,#e11d48)";
|
||||
const glowColor = team === 0 ? "rgba(45,212,191,0.55)" : "rgba(251,113,133,0.55)";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* centre flash */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0.85 }}
|
||||
animate={{ scale: 3.5, opacity: 0 }}
|
||||
transition={{ duration: 0.38 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
|
||||
style={{ width: 44, height: 44, background: glowColor }}
|
||||
/>
|
||||
{BURST_ANGLES.map(p => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
|
||||
animate={{ x: p.x, y: p.y, opacity: 0, scale: 0 }}
|
||||
transition={{ duration: 0.55, ease: [0.2, 0.8, 0.4, 1] }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
|
||||
style={{ width: p.size, height: p.size, background: gradient }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Player hand ---------------------------- */
|
||||
|
||||
function useViewportWidth() {
|
||||
@@ -430,18 +534,21 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
|
||||
const sorted = sortHand(hand);
|
||||
const myTurn = phase === "playing" && turn === 0;
|
||||
// While choosing trump the hakem must see their cards above the chooser overlay.
|
||||
const choosing = phase === "choosing-trump";
|
||||
const n = sorted.length;
|
||||
|
||||
// Compress the fan so every card fits the screen width (no overflow/scroll).
|
||||
const small = vw < 560;
|
||||
const size = vw < 360 ? "sm" : vw < 560 ? "md" : "lg";
|
||||
const cardW = size === "sm" ? 44 : size === "md" ? 60 : 74;
|
||||
const size = vw < 360 ? "sm" : vw < 480 ? "md" : vw < 640 ? "lg" : "xl";
|
||||
const cardW = size === "sm" ? 44 : size === "md" ? 62 : size === "lg" ? 78 : 92;
|
||||
const avail = Math.min(vw - 12, 620);
|
||||
const step = n > 1 ? Math.min(cardW * 0.94, Math.max(15, (avail - cardW) / (n - 1))) : 0;
|
||||
const overlap = step - cardW; // negative inline-start margin
|
||||
|
||||
// Desktop (with a keyboard) gets numbered shortcut badges on playable cards.
|
||||
const showShortcutBadges = vw >= 768;
|
||||
let playableSeq = 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -456,6 +563,8 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
const mid = (n - 1) / 2;
|
||||
const rot = (i - mid) * (small ? 2 : 3.2);
|
||||
const lift = Math.abs(i - mid) * (small ? 2 : 4);
|
||||
const shortcutNum = playable ? ++playableSeq : 0;
|
||||
const badge = shortcutNum >= 1 && shortcutNum <= 10 ? (shortcutNum === 10 ? "0" : String(shortcutNum)) : null;
|
||||
return (
|
||||
<motion.button
|
||||
key={card.id}
|
||||
@@ -471,8 +580,9 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
data-playable={playable ? "1" : "0"}
|
||||
style={{ marginInlineStart: i === 0 ? 0 : overlap }}
|
||||
className={cn(
|
||||
"origin-bottom shrink-0",
|
||||
playable ? "cursor-pointer relative z-30" : "cursor-default"
|
||||
"origin-bottom shrink-0 relative",
|
||||
playable ? "cursor-pointer z-30" : "cursor-default",
|
||||
playable && "card-playable"
|
||||
)}
|
||||
>
|
||||
<PlayingCard
|
||||
@@ -480,8 +590,12 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
size={size}
|
||||
dimmed={dimmed}
|
||||
front={front}
|
||||
className={cn(playable && "ring-2 ring-gold-400/80")}
|
||||
/>
|
||||
{showShortcutBadges && badge && (
|
||||
<span className="absolute -top-2 left-1/2 -translate-x-1/2 z-40 size-5 rounded-full btn-gold text-[11px] font-black grid place-items-center shadow-md">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
@@ -490,6 +604,52 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- Shortcuts hint --------------------------- */
|
||||
|
||||
function ShortcutsHint() {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const vw = useViewportWidth();
|
||||
if (vw < 768) return null; // keyboard shortcuts are desktop-only
|
||||
return (
|
||||
<div className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] ltr:left-4 rtl:right-4 z-50">
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
className="glass rounded-2xl p-3 mb-2 w-56 text-xs text-cream/80 space-y-1.5"
|
||||
>
|
||||
<Row k="1–9 / 0" v={t("keys.play")} />
|
||||
<Row k="Space" v={t("keys.first")} />
|
||||
<Row k="1–4" v={t("keys.trump")} />
|
||||
<Row k="M" v={t("keys.mute")} />
|
||||
<Row k="F" v={t("keys.forfeit")} />
|
||||
<Row k="Esc / Q" v={t("keys.quit")} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition text-gold-400 font-black"
|
||||
title={t("keys.title")}
|
||||
>
|
||||
⌨
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<kbd className="rounded-md bg-navy-900/80 gold-border px-1.5 py-0.5 font-mono text-[10px] text-gold-300">{k}</kbd>
|
||||
<span className="text-cream/70">{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- Turn indicator --------------------------- */
|
||||
|
||||
function TurnIndicator() {
|
||||
@@ -502,19 +662,28 @@ function TurnIndicator() {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={game.turn}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute bottom-[120px] sm:bottom-[150px] left-1/2 -translate-x-1/2 z-30"
|
||||
initial={{ opacity: 0, scale: 0.75, y: 18 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.85, y: -8 }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 24 }}
|
||||
className="absolute bottom-[136px] sm:bottom-[168px] left-1/2 -translate-x-1/2 z-30"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-semibold glass",
|
||||
isYou ? "text-gold-300" : "text-cream/70"
|
||||
)}
|
||||
>
|
||||
{isYou ? t("turn.you") : t("turn.other", { name })}
|
||||
</div>
|
||||
{isYou ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.055, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.05, ease: "easeInOut" }}
|
||||
className="btn-gold press-3d rounded-full px-7 py-2.5 font-black text-[15px] tracking-wide"
|
||||
style={{
|
||||
boxShadow: "0 0 0 3px rgba(212,175,55,0.55), 0 0 28px rgba(212,175,55,0.45), 0 5px 0 rgba(0,0,0,0.32)",
|
||||
}}
|
||||
>
|
||||
✨ {t("turn.you")}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="glass rounded-full px-5 py-2 text-sm font-semibold text-cream/70">
|
||||
{t("turn.other", { name })}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
@@ -525,10 +694,12 @@ function TurnIndicator() {
|
||||
function TurnTimer() {
|
||||
const deadline = useGameStore((s) => s.turnDeadline);
|
||||
const phase = useGameStore((s) => s.game.phase);
|
||||
const stake = useGameStore((s) => s.matchMeta.stake);
|
||||
const speed = useGameStore((s) => s.matchMeta.speed);
|
||||
const secs = useCountdown(deadline);
|
||||
if (deadline == null || secs == null) return null;
|
||||
if (phase !== "playing" && phase !== "choosing-trump") return null;
|
||||
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / TURN_MS));
|
||||
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / turnMsForStake(stake, speed)));
|
||||
const danger = secs <= 5;
|
||||
return (
|
||||
<div className="absolute bottom-[156px] sm:bottom-[190px] left-1/2 -translate-x-1/2 z-30 w-36 sm:w-40 text-center">
|
||||
@@ -808,6 +979,15 @@ function TrumpChooser() {
|
||||
);
|
||||
}
|
||||
|
||||
const CONFETTI_SPECS = Array.from({ length: 22 }, (_, i) => ({
|
||||
id: i,
|
||||
left: 4 + ((i * 4.3) % 92),
|
||||
delay: (i * 0.07) % 1.2,
|
||||
color: i % 4 === 0 ? "#d4af37" : i % 4 === 1 ? "#2dd4bf" : i % 4 === 2 ? "#f5ecd6" : "#fb7185",
|
||||
size: 6 + (i % 4) * 3,
|
||||
rot: (i * 41) % 360,
|
||||
}));
|
||||
|
||||
function RoundOverlay() {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const { t } = useI18n();
|
||||
@@ -817,43 +997,68 @@ function RoundOverlay() {
|
||||
return (
|
||||
<Backdrop>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, y: 20 }}
|
||||
initial={{ scale: 0.82, y: 24 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
className="glass rounded-3xl p-8 text-center max-w-sm w-full"
|
||||
transition={{ type: "spring", stiffness: 220, damping: 20 }}
|
||||
className="glass rounded-3xl p-8 text-center max-w-sm w-full relative overflow-hidden"
|
||||
>
|
||||
{/* confetti on win */}
|
||||
{weWon && CONFETTI_SPECS.map(p => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={{ y: -10, opacity: 1, rotate: p.rot }}
|
||||
animate={{ y: 160, opacity: 0, rotate: p.rot + 450 }}
|
||||
transition={{ duration: 1.5 + p.delay * 0.4, delay: p.delay, ease: "linear" }}
|
||||
className="absolute pointer-events-none rounded-sm"
|
||||
style={{ width: p.size, height: p.size, background: p.color, left: `${p.left}%`, top: 0 }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 16 }}
|
||||
className="text-5xl mb-1"
|
||||
>
|
||||
{weWon ? "🎉" : "😤"}
|
||||
</motion.div>
|
||||
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
|
||||
|
||||
{r.kot && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay: 0.15 }}
|
||||
className="mt-3 inline-block rounded-full btn-gold px-5 py-1.5 text-lg font-black"
|
||||
initial={{ scale: 0, rotate: -15 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 350, damping: 14, delay: 0.15 }}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-full btn-gold press-3d px-6 py-2 text-xl font-black"
|
||||
>
|
||||
{t("round.kot")}🔥
|
||||
{t("round.kot")} 🔥
|
||||
</motion.div>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-4 text-xl font-bold",
|
||||
weWon ? "text-teal-300" : "text-rose-300"
|
||||
)}
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.22 }}
|
||||
className={cn("mt-4 text-2xl font-black", weWon ? "text-teal-300" : "text-rose-300")}
|
||||
>
|
||||
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
|
||||
</motion.p>
|
||||
<p className="text-cream/70 mt-2 font-semibold">
|
||||
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
|
||||
</p>
|
||||
<p className="text-cream/70 mt-2">
|
||||
{t("round.score", {
|
||||
us: game.matchScore[0],
|
||||
them: game.matchScore[1],
|
||||
})}
|
||||
</p>
|
||||
<p className="text-cream/40 text-sm mt-5 animate-pulse">
|
||||
{t("round.next")}
|
||||
</p>
|
||||
<p className="text-cream/40 text-sm mt-5 animate-pulse">{t("round.next")}</p>
|
||||
</motion.div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
const WIN_COINS = Array.from({ length: 14 }, (_, i) => ({
|
||||
id: i,
|
||||
left: 5 + ((i * 6.8) % 88),
|
||||
delay: (i * 0.11) % 1.6,
|
||||
fontSize: 18 + (i % 3) * 10,
|
||||
}));
|
||||
|
||||
function MatchOverlay({ onExit }: { onExit: () => void }) {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const { t } = useI18n();
|
||||
@@ -861,37 +1066,46 @@ function MatchOverlay({ onExit }: { onExit: () => void }) {
|
||||
return (
|
||||
<Backdrop>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="glass rounded-3xl p-9 text-center max-w-sm w-full"
|
||||
initial={{ scale: 0.82, y: 16 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 18 }}
|
||||
className="glass rounded-3xl p-9 text-center max-w-sm w-full relative overflow-hidden"
|
||||
>
|
||||
{/* coin rain on win */}
|
||||
{youWin && WIN_COINS.map(c => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
initial={{ top: "-30px", opacity: 0, rotate: 0 }}
|
||||
animate={{ top: "110%", opacity: [0, 1, 1, 0], rotate: 540 }}
|
||||
transition={{ duration: 2.2 + c.delay * 0.3, delay: c.delay, ease: "easeIn" }}
|
||||
className="absolute pointer-events-none select-none"
|
||||
style={{ left: `${c.left}%`, fontSize: c.fontSize }}
|
||||
>
|
||||
🪙
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
initial={{ rotate: -15, scale: 0 }}
|
||||
initial={{ rotate: -20, scale: 0 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 160 }}
|
||||
className="text-6xl mb-3"
|
||||
transition={{ type: "spring", stiffness: 180, damping: 14 }}
|
||||
className="text-7xl mb-3 relative"
|
||||
>
|
||||
{youWin ? "🏆" : "🎴"}
|
||||
</motion.div>
|
||||
|
||||
<h2 className="gold-text text-3xl font-black">{t("match.over")}</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 text-2xl font-bold",
|
||||
youWin ? "text-gold-300" : "text-rose-300"
|
||||
)}
|
||||
>
|
||||
<p className={cn("mt-3 text-2xl font-bold", youWin ? "text-gold-300" : "text-rose-300")}>
|
||||
{youWin ? t("match.youWin") : t("match.youLose")}
|
||||
</p>
|
||||
<p className="text-cream/70 mt-2">
|
||||
{t("round.score", {
|
||||
us: game.matchScore[0],
|
||||
them: game.matchScore[1],
|
||||
})}
|
||||
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
|
||||
</p>
|
||||
|
||||
<MatchPlayersList />
|
||||
|
||||
<div className="mt-7 flex gap-3">
|
||||
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3">
|
||||
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3 font-black text-lg">
|
||||
{t("match.menu")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore, type Screen } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { sound } from "@/lib/sound";
|
||||
import { SPEED_TARGET_SCORE } from "@/lib/online/gamification";
|
||||
import { SUIT_SYMBOL } from "@/lib/hokm/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TopBar } from "./online/TopBar";
|
||||
|
||||
export function HomeScreen() {
|
||||
@@ -39,9 +42,15 @@ export function HomeScreen() {
|
||||
go(screen);
|
||||
};
|
||||
|
||||
const [speed, setSpeed] = useState(false);
|
||||
|
||||
const playVsComputer = () => {
|
||||
const you = profile?.displayName || t("seat.you");
|
||||
newMatch({ names: [you, "آرش", "کیان", "نیلوفر"], targetScore: 7 });
|
||||
newMatch({
|
||||
names: [you, "آرش", "کیان", "نیلوفر"],
|
||||
targetScore: speed ? SPEED_TARGET_SCORE : 7,
|
||||
speed,
|
||||
});
|
||||
goGame("home");
|
||||
};
|
||||
|
||||
@@ -102,9 +111,31 @@ export function HomeScreen() {
|
||||
<PrimaryCard
|
||||
icon={<Bot className="size-6" />}
|
||||
title={t("menu.vsComputer")}
|
||||
desc={t("menu.vsComputerDesc")}
|
||||
desc={speed ? t("speed.desc") : t("menu.vsComputerDesc")}
|
||||
onClick={playVsComputer}
|
||||
/>
|
||||
{/* Normal / Speed mode picker */}
|
||||
<div className="mt-2 glass rounded-2xl p-1 flex gap-1">
|
||||
<button
|
||||
onClick={() => setSpeed(false)}
|
||||
className={cn(
|
||||
"flex-1 rounded-xl py-2 text-xs font-bold transition flex items-center justify-center gap-1.5",
|
||||
!speed ? "btn-gold" : "text-cream/60 hover:text-cream"
|
||||
)}
|
||||
>
|
||||
{t("speed.normal")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSpeed(true)}
|
||||
className={cn(
|
||||
"flex-1 rounded-xl py-2 text-xs font-bold transition flex items-center justify-center gap-1.5",
|
||||
speed ? "btn-gold" : "text-cream/60 hover:text-cream"
|
||||
)}
|
||||
>
|
||||
<Zap className={cn("size-3.5", speed && "fill-current")} />
|
||||
{t("speed.label")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* tiles */}
|
||||
|
||||
@@ -2,26 +2,20 @@
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Card, SUIT_IS_RED, SUIT_SYMBOL, rankLabel } from "@/lib/hokm/types";
|
||||
import type { CardBackPattern } from "@/lib/online/types";
|
||||
import { cardBackMotif, cardBackVisual } from "@/lib/cardBack";
|
||||
|
||||
const SIZES = {
|
||||
sm: { w: 44, h: 62, rank: "text-base", pip: "text-lg", center: "text-2xl" },
|
||||
md: { w: 60, h: 84, rank: "text-lg", pip: "text-xl", center: "text-3xl" },
|
||||
lg: { w: 74, h: 104, rank: "text-xl", pip: "text-2xl", center: "text-4xl" },
|
||||
sm: { w: 44, h: 62, rank: "text-[11px]", center: "text-2xl", radius: 7 },
|
||||
md: { w: 62, h: 87, rank: "text-sm", center: "text-3xl", radius: 9 },
|
||||
lg: { w: 78, h: 110, rank: "text-base", center: "text-4xl", radius: 11 },
|
||||
xl: { w: 92, h: 130, rank: "text-lg", center: "text-5xl", radius: 13 },
|
||||
} as const;
|
||||
|
||||
export type CardSize = keyof typeof SIZES;
|
||||
|
||||
interface CardBack {
|
||||
c1: string;
|
||||
c2: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
interface CardFront {
|
||||
bg1: string;
|
||||
bg2: string;
|
||||
border: string;
|
||||
}
|
||||
interface CardBack { c1: string; c2: string; accent: string; pattern?: CardBackPattern; motif?: string; }
|
||||
interface CardFront { bg1: string; bg2: string; border: string; }
|
||||
|
||||
interface Props {
|
||||
card?: Card;
|
||||
@@ -44,77 +38,114 @@ export function PlayingCard({
|
||||
}: Props) {
|
||||
const s = SIZES[size];
|
||||
|
||||
/* ── Face-down ─────────────────────────────────────────────────── */
|
||||
if (faceDown || !card) {
|
||||
const styled = back
|
||||
? {
|
||||
width: s.w,
|
||||
height: s.h,
|
||||
borderRadius: 8,
|
||||
background: `repeating-linear-gradient(45deg, ${back.accent}40 0 6px, transparent 6px 12px), linear-gradient(160deg, ${back.c1}, ${back.c2})`,
|
||||
border: `1px solid ${back.accent}80`,
|
||||
boxShadow: "0 6px 14px rgba(0,0,0,0.4)",
|
||||
}
|
||||
: { width: s.w, height: s.h };
|
||||
const visual = back ? cardBackVisual(back.c1, back.c2, back.accent, back.pattern) : null;
|
||||
const styled = back && visual ? {
|
||||
width: s.w, height: s.h, borderRadius: s.radius,
|
||||
background: visual.background,
|
||||
backgroundSize: visual.backgroundSize,
|
||||
border: `1.5px solid ${back.accent}88`,
|
||||
boxShadow: "0 6px 18px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.12)",
|
||||
} : { width: s.w, height: s.h };
|
||||
const motif = back ? cardBackMotif(back.pattern, back.motif) : "✦";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(!back && "card-back", "rounded-lg shrink-0", className)}
|
||||
style={styled}
|
||||
className={cn(!back && "card-back", "shrink-0", className)}
|
||||
style={{ ...styled, borderRadius: s.radius }}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="h-full w-full rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
className={cn("text-lg font-bold", !back && "text-gold-500/70")}
|
||||
style={back ? { color: `${back.accent}cc` } : undefined}
|
||||
>
|
||||
✦
|
||||
</div>
|
||||
<div
|
||||
className="h-full w-full flex items-center justify-center"
|
||||
style={{ borderRadius: s.radius }}
|
||||
>
|
||||
{motif && (
|
||||
<span
|
||||
className={cn("font-bold select-none", !back && "text-gold-500/70")}
|
||||
style={{
|
||||
fontSize: s.w * 0.34,
|
||||
color: back ? `${back.accent}dd` : undefined,
|
||||
textShadow: back ? `0 1px 3px rgba(0,0,0,0.45)` : undefined,
|
||||
}}
|
||||
>
|
||||
{motif}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const red = SUIT_IS_RED[card.suit];
|
||||
const color = red ? "text-rose-600" : "text-slate-900";
|
||||
const symbol = SUIT_SYMBOL[card.suit];
|
||||
/* ── Face-up ────────────────────────────────────────────────────── */
|
||||
const red = SUIT_IS_RED[card.suit];
|
||||
const symbol = SUIT_SYMBOL[card.suit];
|
||||
const label = rankLabel(card.rank);
|
||||
|
||||
// UNO-style: suit-aware background
|
||||
const cardBg = front
|
||||
? `linear-gradient(160deg,${front.bg1},${front.bg2})`
|
||||
: red
|
||||
? "linear-gradient(160deg,#fff8f7,#fdecea)"
|
||||
: "linear-gradient(160deg,#fefefe,#f4f2ec)";
|
||||
|
||||
const borderColor = front?.border ?? (red ? "rgba(200,70,70,0.22)" : "rgba(50,50,80,0.15)");
|
||||
|
||||
// Bold suit colours (UNO-style vivid)
|
||||
const inkColor = red ? "#c0202a" : "#1c1c38";
|
||||
const pipColor = red ? "#e03540" : "#2a2a50";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"card-face rounded-lg shrink-0 relative select-none transition-opacity",
|
||||
dimmed && "opacity-45",
|
||||
className
|
||||
)}
|
||||
className={cn("shrink-0 relative select-none transition-opacity", dimmed && "opacity-40", className)}
|
||||
style={{
|
||||
width: s.w,
|
||||
height: s.h,
|
||||
...(front
|
||||
? { background: `linear-gradient(160deg, ${front.bg1}, ${front.bg2})`, borderColor: front.border }
|
||||
: {}),
|
||||
width: s.w, height: s.h, borderRadius: s.radius,
|
||||
background: cardBg,
|
||||
border: `1.5px solid ${borderColor}`,
|
||||
boxShadow: "0 6px 18px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.85)",
|
||||
}}
|
||||
>
|
||||
<div className={cn("absolute top-1 left-1.5 leading-none font-bold", color, s.rank)}>
|
||||
<div>{rankLabel(card.rank)}</div>
|
||||
<div className={s.rank}>{symbol}</div>
|
||||
</div>
|
||||
{/* Top-left corner */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center font-bold",
|
||||
color,
|
||||
s.center
|
||||
)}
|
||||
className={cn("absolute top-[3px] left-[5px] leading-[1.1] font-black", s.rank)}
|
||||
style={{ color: inkColor }}
|
||||
>
|
||||
<div>{label}</div>
|
||||
<div style={{ color: pipColor, fontSize: "0.82em" }}>{symbol}</div>
|
||||
</div>
|
||||
|
||||
{/* Center symbol — large, bold, slightly shadowed */}
|
||||
<div
|
||||
className={cn("absolute inset-0 flex items-center justify-center font-black", s.center)}
|
||||
style={{
|
||||
color: inkColor,
|
||||
textShadow: red
|
||||
? "0 2px 10px rgba(210,40,40,0.18)"
|
||||
: "0 2px 10px rgba(28,28,56,0.12)",
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</div>
|
||||
|
||||
{/* Bottom-right corner (rotated 180°) */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-1 right-1.5 leading-none font-bold rotate-180",
|
||||
color,
|
||||
s.rank
|
||||
)}
|
||||
className={cn("absolute bottom-[3px] right-[5px] leading-[1.1] font-black rotate-180", s.rank)}
|
||||
style={{ color: inkColor }}
|
||||
>
|
||||
<div>{rankLabel(card.rank)}</div>
|
||||
<div className={s.rank}>{symbol}</div>
|
||||
<div>{label}</div>
|
||||
<div style={{ color: pipColor, fontSize: "0.82em" }}>{symbol}</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle inner rim for red suits — UNO-style */}
|
||||
{red && (
|
||||
<div
|
||||
className="absolute inset-[3px] pointer-events-none"
|
||||
style={{
|
||||
borderRadius: Math.max(0, s.radius - 4),
|
||||
border: "1px solid rgba(210,40,40,0.14)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useCelebrationStore } from "@/lib/celebration-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { sound } from "@/lib/sound";
|
||||
import { stickerPackForAchievement } from "@/lib/online/gamification";
|
||||
import { Sticker } from "./Sticker";
|
||||
|
||||
function useCountUp(target: number, ms = 900, run = true) {
|
||||
const [v, setV] = useState(0);
|
||||
@@ -125,27 +127,36 @@ function Card() {
|
||||
{/* achievements */}
|
||||
{current.achievements && current.achievements.length > 0 && (
|
||||
<div className="relative mt-4 space-y-2">
|
||||
{current.achievements.map((a, i) => (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 + i * 0.15, type: "spring", stiffness: 200 }}
|
||||
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
|
||||
>
|
||||
<span className="text-2xl">{a.icon}</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[10px] text-gold-400">{t("reward.newAchievement")}</span>
|
||||
<span className="block text-sm font-semibold text-cream truncate">
|
||||
{locale === "fa" ? a.nameFa : a.nameEn}
|
||||
{current.achievements.map((a, i) => {
|
||||
const pack = stickerPackForAchievement(a.id);
|
||||
return (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 + i * 0.15, type: "spring", stiffness: 200 }}
|
||||
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
|
||||
>
|
||||
<span className="text-2xl">{a.icon}</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[10px] text-gold-400">{t("reward.newAchievement")}</span>
|
||||
<span className="block text-sm font-semibold text-cream truncate">
|
||||
{locale === "fa" ? a.nameFa : a.nameEn}
|
||||
</span>
|
||||
{pack && (
|
||||
<span className="mt-0.5 flex items-center gap-1 text-[10px] text-teal-300">
|
||||
{pack.stickers[0] && <Sticker id={pack.stickers[0]} size={14} />}
|
||||
{t("reward.stickerUnlocked")}: {locale === "fa" ? pack.nameFa : pack.nameEn}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-gold-300 flex items-center gap-1 shrink-0">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
<span className="text-xs text-gold-300 flex items-center gap-1 shrink-0">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -12,12 +12,28 @@ import { sound } from "@/lib/sound";
|
||||
import { DailyRewardState } from "@/lib/online/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Per-day themed icons and colour accents
|
||||
const DAY_META = [
|
||||
{ icon: "🎁", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "💰", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "⭐", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "💎", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "🔥", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "🏆", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "👑", label: "special", accent: "from-gold-600/30 to-gold-500/10" },
|
||||
] as const;
|
||||
|
||||
// Coin-rise particles shown when claiming day 7
|
||||
const MEGA_COINS = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i, left: 8 + (i * 9) % 84, delay: i * 0.12,
|
||||
}));
|
||||
|
||||
export function DailyRewardModal() {
|
||||
const open = useUIStore((s) => s.dailyModalOpen);
|
||||
const close = useUIStore((s) => s.closeDaily);
|
||||
const open = useUIStore((s) => s.dailyModalOpen);
|
||||
const close = useUIStore((s) => s.closeDaily);
|
||||
const refreshProfile = useSessionStore((s) => s.refreshProfile);
|
||||
const { t } = useI18n();
|
||||
const [state, setState] = useState<DailyRewardState | null>(null);
|
||||
const [state, setState] = useState<DailyRewardState | null>(null);
|
||||
const [claimed, setClaimed] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,6 +51,8 @@ export function DailyRewardModal() {
|
||||
setState(await getService().getDailyState());
|
||||
};
|
||||
|
||||
const isMegaDay = state?.day === 7;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
@@ -43,68 +61,119 @@ export function DailyRewardModal() {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={close}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/85 backdrop-blur-sm p-5"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 16 }}
|
||||
initial={{ scale: 0.88, y: 18 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 220, damping: 20 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="glass rounded-3xl p-7 w-full max-w-sm text-center"
|
||||
className="glass rounded-3xl p-7 w-full max-w-sm text-center relative overflow-hidden"
|
||||
>
|
||||
<h2 className="gold-text text-2xl font-black">{t("daily.title")}</h2>
|
||||
{/* gold glow */}
|
||||
<div className="pointer-events-none absolute -inset-10 bg-gold-500/10 blur-3xl" />
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mt-5">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -15 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 240, damping: 16 }}
|
||||
className="relative text-5xl mb-1"
|
||||
>
|
||||
🎁
|
||||
</motion.div>
|
||||
<h2 className="relative gold-text text-2xl font-black">{t("daily.title")}</h2>
|
||||
|
||||
{/* Day cards grid: 3-col rows, day 7 spans full width */}
|
||||
<div className="relative grid grid-cols-3 gap-2 mt-5">
|
||||
{DAILY_REWARDS.map((coins, i) => {
|
||||
const day = i + 1;
|
||||
const day = i + 1;
|
||||
const meta = DAY_META[i];
|
||||
const isToday = state?.day === day && state?.available;
|
||||
const isPast = state ? day < state.day : false;
|
||||
const isPast = state ? day < state.day : false;
|
||||
const isMega = day === 7;
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={day}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay: i * 0.06, type: "spring", stiffness: 280, damping: 22 }}
|
||||
className={cn(
|
||||
"rounded-xl py-2.5 flex flex-col items-center gap-1 border",
|
||||
i === 6 && "col-span-4 flex-row justify-center gap-3",
|
||||
"rounded-2xl py-3 px-2 flex flex-col items-center gap-1.5 border transition-all",
|
||||
isMega && "col-span-3 flex-row justify-center gap-4 py-4 px-5",
|
||||
isToday
|
||||
? "btn-gold border-transparent"
|
||||
? "btn-gold border-transparent shadow-lg"
|
||||
: isPast
|
||||
? "bg-navy-900/50 border-teal-500/30 opacity-60"
|
||||
: "bg-navy-900/70 gold-border"
|
||||
? "bg-navy-900/50 border-teal-500/30 opacity-55"
|
||||
: `bg-gradient-to-b ${meta.accent} gold-border`
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-[10px]", isToday ? "text-[#2a1f04]" : "text-cream/60")}>
|
||||
{t("daily.day", { n: day })}
|
||||
<span className={cn("text-2xl leading-none", isMega && "text-3xl")}>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm font-bold",
|
||||
<div className={cn("flex flex-col items-center gap-0.5", isMega && "items-start")}>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold leading-none",
|
||||
isToday ? "text-[#2a1f04]" : "text-cream/55",
|
||||
isMega && !isToday && "text-gold-400"
|
||||
)}>
|
||||
{meta.label === "special"
|
||||
? t("daily.special")
|
||||
: t("daily.day", { n: day })}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 font-black tabular-nums",
|
||||
isMega ? "text-lg" : "text-sm",
|
||||
isToday ? "text-[#2a1f04]" : "text-gold-300"
|
||||
)}
|
||||
>
|
||||
{coins}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</div>
|
||||
)}>
|
||||
{coins.toLocaleString()}
|
||||
<Coins className={cn("shrink-0", isMega ? "size-4" : "size-3")} />
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* mega-day coin burst when claimed */}
|
||||
{claimed != null && isMegaDay && MEGA_COINS.map(c => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
initial={{ bottom: 60, opacity: 0, rotate: 0 }}
|
||||
animate={{ bottom: "110%", opacity: [0, 1, 1, 0], rotate: 540 }}
|
||||
transition={{ duration: 1.6 + c.delay * 0.2, delay: c.delay, ease: "easeOut" }}
|
||||
className="absolute pointer-events-none text-2xl select-none"
|
||||
style={{ left: `${c.left}%` }}
|
||||
>
|
||||
🪙
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Status row */}
|
||||
{claimed != null ? (
|
||||
<p className="mt-5 text-teal-300 font-bold flex items-center justify-center gap-1.5">
|
||||
+{claimed} <Coins className="size-4 text-gold-400" /> {t("daily.claimed")}
|
||||
</p>
|
||||
<motion.p
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260 }}
|
||||
className="relative mt-5 text-teal-300 font-black text-base flex items-center justify-center gap-2"
|
||||
>
|
||||
🎉 +{claimed.toLocaleString()} <Coins className="size-4 text-gold-400" /> {t("daily.claimed")}
|
||||
</motion.p>
|
||||
) : state?.available ? (
|
||||
<button onClick={claim} className="btn-gold w-full rounded-xl py-3 mt-5">
|
||||
{t("daily.claim")}
|
||||
</button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={claim}
|
||||
className="relative press-3d btn-gold w-full rounded-xl py-3.5 mt-5 font-black text-base"
|
||||
>
|
||||
{t("daily.claim")} {DAY_META[(state.day ?? 1) - 1]?.icon}
|
||||
</motion.button>
|
||||
) : (
|
||||
<p className="mt-5 text-cream/50 text-sm">{t("daily.come")}</p>
|
||||
<p className="relative mt-5 text-cream/50 text-sm">{t("daily.come")}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={close}
|
||||
className="mt-3 text-cream/50 text-sm hover:text-cream/80"
|
||||
>
|
||||
<button onClick={close} className="relative mt-3 text-cream/50 text-sm hover:text-cream/80">
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MAX_LEVEL, xpNeededForLevel } from "@/lib/online/gamification";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
/**
|
||||
* Always-visible level + XP progress chip. Tapping it opens the profile.
|
||||
* Rendered on Home (TopBar) and at the top of every inner screen (ScreenHeader)
|
||||
* so the player can always see their level and how close the next one is.
|
||||
*/
|
||||
export function LevelXpBar({
|
||||
className,
|
||||
showAvatar = true,
|
||||
}: {
|
||||
className?: string;
|
||||
showAvatar?: boolean;
|
||||
}) {
|
||||
const profile = useSessionStore((s) => s.profile);
|
||||
const go = useUIStore((s) => s.go);
|
||||
const { t } = useI18n();
|
||||
if (!profile) return null;
|
||||
|
||||
const maxed = profile.level >= MAX_LEVEL;
|
||||
const need = xpNeededForLevel(profile.level);
|
||||
const pct = maxed ? 100 : Math.min(100, Math.max(0, Math.round((profile.xp / need) * 100)));
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => go("profile")}
|
||||
className={cn(
|
||||
"glass rounded-full ltr:pl-1.5 rtl:pr-1.5 ltr:pr-3 rtl:pl-3 py-1.5 flex items-center gap-2.5 active:scale-[0.98] transition",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
<span className="relative size-8 rounded-full bg-navy-900 gold-border flex items-center justify-center overflow-hidden shrink-0">
|
||||
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 32 : 22} />
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 rounded-full bg-navy-950 gold-border px-1 text-[8px] font-black text-gold-300 leading-tight">
|
||||
{profile.level}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 min-w-[84px]">
|
||||
<span className="flex items-center justify-between text-[10px] leading-none mb-1">
|
||||
<span className="text-gold-300 font-bold">
|
||||
{t("common.level")} {profile.level}
|
||||
</span>
|
||||
<span className="text-cream/45 tabular-nums">
|
||||
{maxed ? "MAX" : `${profile.xp.toLocaleString()}/${need.toLocaleString()}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="block h-1.5 rounded-full bg-navy-900 overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-full bg-gradient-to-r from-gold-500 to-gold-300 transition-[width] duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Check, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { sound } from "@/lib/sound";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
@@ -13,6 +14,7 @@ export function MatchPlayersList() {
|
||||
const { t } = useI18n();
|
||||
const seatPlayers = useGameStore((s) => s.seatPlayers);
|
||||
const myId = useSessionStore((s) => s.profile?.id);
|
||||
const viewProfile = useUIStore((s) => s.viewProfile);
|
||||
const [sent, setSent] = useState<Record<string, boolean>>({});
|
||||
|
||||
if (!seatPlayers.length) return null;
|
||||
@@ -21,7 +23,7 @@ export function MatchPlayersList() {
|
||||
setSent((p) => ({ ...p, [id]: true }));
|
||||
sound.play("click");
|
||||
try {
|
||||
await getService().addFriend(id);
|
||||
await getService().addFriendById(id);
|
||||
} catch {
|
||||
/* ignore — request is best-effort */
|
||||
}
|
||||
@@ -36,21 +38,42 @@ export function MatchPlayersList() {
|
||||
const canAdd = !!p.id && !p.isBot && p.id !== myId;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2.5 glass rounded-xl px-2.5 py-1.5">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
|
||||
{p.avatar}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-semibold text-cream truncate">
|
||||
{p.name}
|
||||
{isMe && <span className="text-gold-300 font-normal"> ({t("match.you")})</span>}
|
||||
{p.isBot && <span className="text-cream/35 font-normal"> ({t("match.bot")})</span>}
|
||||
</span>
|
||||
{p.level > 0 && (
|
||||
<span className="block text-[10px] text-cream/45">
|
||||
{t("common.level")} {p.level}
|
||||
{canAdd ? (
|
||||
<button
|
||||
onClick={() => viewProfile(p.id!)}
|
||||
className="flex items-center gap-2.5 flex-1 min-w-0 text-start active:scale-[0.99] transition"
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
|
||||
{p.avatar}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-semibold text-cream truncate">{p.name}</span>
|
||||
{p.level > 0 && (
|
||||
<span className="block text-[10px] text-cream/45">
|
||||
{t("common.level")} {p.level}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
|
||||
{p.avatar}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-semibold text-cream truncate">
|
||||
{p.name}
|
||||
{isMe && <span className="text-gold-300 font-normal"> ({t("match.you")})</span>}
|
||||
{p.isBot && <span className="text-cream/35 font-normal"> ({t("match.bot")})</span>}
|
||||
</span>
|
||||
{p.level > 0 && (
|
||||
<span className="block text-[10px] text-cream/45">
|
||||
{t("common.level")} {p.level}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{canAdd &&
|
||||
(sent[p.id!] ? (
|
||||
<span className="text-[11px] text-teal-300 flex items-center gap-1 shrink-0">
|
||||
|
||||
@@ -1,31 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { Coins, Sparkles, Star, TrendingDown, TrendingUp, Zap } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { sound } from "@/lib/sound";
|
||||
import { stickerPackForAchievement } from "@/lib/online/gamification";
|
||||
import { MatchPlayersList } from "./MatchPlayersList";
|
||||
import { Sticker } from "./Sticker";
|
||||
import { RewardResult } from "@/lib/online/types";
|
||||
|
||||
/** Animated count-up used for the coins-won hero. */
|
||||
function CountUp({ to }: { to: number }) {
|
||||
function CountUp({ to, ms = 900 }: { to: number; ms?: number }) {
|
||||
const [n, setN] = useState(0);
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const start = performance.now();
|
||||
const dur = 900;
|
||||
const tick = (now: number) => {
|
||||
const p = Math.min(1, (now - start) / dur);
|
||||
setN(Math.round(to * (1 - Math.pow(1 - p, 3)))); // ease-out
|
||||
const p = Math.min(1, (now - start) / ms);
|
||||
setN(Math.round(to * (1 - Math.pow(1 - p, 3))));
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [to]);
|
||||
}, [to, ms]);
|
||||
return <span className="tabular-nums">{n.toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
/* floating coins that rise from the bottom on a win */
|
||||
const COIN_SPECS = Array.from({ length: 16 }, (_, i) => ({
|
||||
id: i,
|
||||
left: 4 + ((i * 6.1) % 90),
|
||||
delay: (i * 0.12) % 1.8,
|
||||
size: 16 + (i % 3) * 10,
|
||||
}));
|
||||
|
||||
function FloatingCoins() {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-3xl">
|
||||
{COIN_SPECS.map(c => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
initial={{ bottom: "-20px", opacity: 0, rotate: 0 }}
|
||||
animate={{ bottom: "105%", opacity: [0, 1, 1, 0], rotate: 540 }}
|
||||
transition={{ duration: 2 + c.delay * 0.4, delay: c.delay, ease: "easeOut" }}
|
||||
className="absolute select-none"
|
||||
style={{ left: `${c.left}%`, fontSize: c.size }}
|
||||
>
|
||||
🪙
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostMatchRewardsModal({
|
||||
reward,
|
||||
won,
|
||||
@@ -50,61 +77,67 @@ export function PostMatchRewardsModal({
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/85 backdrop-blur-sm p-5"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, y: 24 }}
|
||||
initial={{ scale: 0.82, y: 28 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
className="glass rounded-3xl p-7 w-full max-w-sm text-center"
|
||||
transition={{ type: "spring", stiffness: 200, damping: 18 }}
|
||||
className="glass rounded-3xl p-7 w-full max-w-sm text-center relative overflow-hidden"
|
||||
>
|
||||
{/* radiating bg glow */}
|
||||
<div
|
||||
className="pointer-events-none absolute -inset-10 blur-3xl opacity-30"
|
||||
style={{ background: won ? "radial-gradient(circle,#d4af37,transparent 65%)" : "radial-gradient(circle,#fb7185,transparent 65%)" }}
|
||||
/>
|
||||
|
||||
{/* floating coins for wins */}
|
||||
{won && reward.coinsDelta > 0 && <FloatingCoins />}
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -15 }}
|
||||
initial={{ scale: 0, rotate: -20 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 160, delay: 0.1 }}
|
||||
className="text-5xl mb-2"
|
||||
transition={{ type: "spring", stiffness: 180, delay: 0.1 }}
|
||||
className="text-6xl mb-2 relative"
|
||||
>
|
||||
{won ? "🏆" : "🎴"}
|
||||
</motion.div>
|
||||
<h2 className="gold-text text-2xl font-black">{t("reward.title")}</h2>
|
||||
<p className={"mt-1 font-bold " + (won ? "text-teal-300" : "text-rose-300")}>
|
||||
|
||||
<h2 className="gold-text text-2xl font-black relative">{t("reward.title")}</h2>
|
||||
<p className={"relative mt-1 font-bold text-lg " + (won ? "text-teal-300" : "text-rose-300")}>
|
||||
{won ? t("reward.win") : t("reward.lose")}
|
||||
</p>
|
||||
|
||||
{/* Coins-won hero (animated count-up) */}
|
||||
{/* Hero coins count-up */}
|
||||
{won && reward.coinsDelta > 0 && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 14, delay: 0.15 }}
|
||||
className="mt-4 flex items-center justify-center gap-2"
|
||||
transition={{ type: "spring", stiffness: 200, damping: 14, delay: 0.18 }}
|
||||
className="relative mt-4 flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-4xl font-black gold-text">
|
||||
+<CountUp to={reward.coinsDelta} />
|
||||
<span className="text-5xl font-black gold-text">
|
||||
+<CountUp to={reward.coinsDelta} ms={1100} />
|
||||
</span>
|
||||
<motion.span
|
||||
animate={{ rotate: [0, -12, 12, 0], y: [0, -4, 0] }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
animate={{ rotate: [0, -14, 14, -8, 0], y: [0, -6, 0] }}
|
||||
transition={{ duration: 0.9, delay: 0.38 }}
|
||||
>
|
||||
<Coins className="size-8 text-gold-400" />
|
||||
<Coins className="size-9 text-gold-400" />
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 space-y-2.5">
|
||||
<div className="relative mt-5 space-y-2">
|
||||
{reward.ratingDelta !== 0 && (
|
||||
<RewardRow
|
||||
icon={
|
||||
reward.ratingDelta > 0 ? (
|
||||
<TrendingUp className="size-4 text-teal-300" />
|
||||
) : (
|
||||
<TrendingDown className="size-4 text-rose-300" />
|
||||
)
|
||||
}
|
||||
icon={reward.ratingDelta > 0
|
||||
? <TrendingUp className="size-4 text-teal-300" />
|
||||
: <TrendingDown className="size-4 text-rose-300" />}
|
||||
label={t("reward.rating")}
|
||||
value={sign(reward.ratingDelta)}
|
||||
positive={reward.ratingDelta > 0}
|
||||
delay={0.2}
|
||||
delay={0.22}
|
||||
/>
|
||||
)}
|
||||
<RewardRow
|
||||
@@ -112,64 +145,90 @@ export function PostMatchRewardsModal({
|
||||
label={t("reward.coins")}
|
||||
value={sign(reward.coinsDelta)}
|
||||
positive={reward.coinsDelta >= 0}
|
||||
delay={0.3}
|
||||
delay={0.32}
|
||||
/>
|
||||
<RewardRow
|
||||
icon={<Star className="size-4 text-gold-400" />}
|
||||
label={t("reward.xp")}
|
||||
value={`+${reward.xpGained}`}
|
||||
positive
|
||||
delay={0.4}
|
||||
delay={0.42}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* XP bar fill animation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.55 }}
|
||||
className="relative mt-3 h-2 rounded-full bg-navy-900 overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ width: "0%" }}
|
||||
animate={{ width: "100%" }}
|
||||
transition={{ duration: 1.2, delay: 0.65, ease: "easeOut" }}
|
||||
className="h-full rounded-full"
|
||||
style={{ background: "linear-gradient(90deg,#d4af37,#f1da8a)" }}
|
||||
/>
|
||||
<Zap className="absolute right-1 top-1/2 -translate-y-1/2 size-2.5 text-gold-300" />
|
||||
</motion.div>
|
||||
|
||||
{reward.leveledUp && (
|
||||
<Banner delay={0.5} text={`${t("reward.levelUp")} → ${reward.levelAfter}`} />
|
||||
<Banner delay={0.58} text={`${t("reward.levelUp")} → ${reward.levelAfter}`} color="gold" />
|
||||
)}
|
||||
{reward.promoted && (
|
||||
<Banner delay={0.62} text={t("reward.promoted")} color="teal" />
|
||||
)}
|
||||
{reward.promoted && <Banner delay={0.55} text={t("reward.promoted")} />}
|
||||
|
||||
{reward.newAchievements.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{reward.newAchievements.map((a, i) => (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 + i * 0.12 }}
|
||||
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
|
||||
>
|
||||
<span className="text-xl">{a.icon}</span>
|
||||
<span className="flex-1">
|
||||
<span className="block text-[10px] text-gold-400">
|
||||
{t("reward.newAchievement")}
|
||||
<div className="relative mt-4 space-y-2">
|
||||
{reward.newAchievements.map((a, i) => {
|
||||
const pack = stickerPackForAchievement(a.id);
|
||||
return (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.68 + i * 0.12, type: "spring", stiffness: 200 }}
|
||||
className="glass rounded-xl px-3 py-2.5 flex items-center gap-2.5 text-start"
|
||||
>
|
||||
<span className="text-2xl">{a.icon}</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[10px] text-gold-400">{t("reward.newAchievement")}</span>
|
||||
<span className="block text-sm font-semibold text-cream truncate">
|
||||
{locale === "fa" ? a.nameFa : a.nameEn}
|
||||
</span>
|
||||
{pack && (
|
||||
<span className="mt-0.5 flex items-center gap-1 text-[10px] text-teal-300">
|
||||
{pack.stickers[0] && <Sticker id={pack.stickers[0]} size={14} />}
|
||||
{t("reward.stickerUnlocked")}: {locale === "fa" ? pack.nameFa : pack.nameEn}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-sm text-cream font-semibold">
|
||||
{locale === "fa" ? a.nameFa : a.nameEn}
|
||||
<span className="text-xs text-gold-300 flex items-center gap-1 shrink-0 font-bold">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-gold-300 flex items-center gap-1">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reward.newTitles.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="relative mt-3 space-y-2">
|
||||
{reward.newTitles.map((tt, i) => (
|
||||
<motion.div
|
||||
key={tt.id}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 20 : -20 }}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.7 + i * 0.12 }}
|
||||
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
|
||||
transition={{ delay: 0.76 + i * 0.12 }}
|
||||
className="glass rounded-xl px-3 py-2.5 flex items-center gap-2.5 text-start"
|
||||
>
|
||||
<span className="text-xl">🏷️</span>
|
||||
<span className="text-2xl">🏷️</span>
|
||||
<span className="flex-1">
|
||||
<span className="block text-[10px] text-gold-400">{t("reward.newTitle")}</span>
|
||||
<span className="block text-sm text-cream font-semibold">
|
||||
<span className="block text-sm font-semibold text-cream">
|
||||
{locale === "fa" ? tt.nameFa : tt.nameEn}
|
||||
</span>
|
||||
</span>
|
||||
@@ -180,7 +239,7 @@ export function PostMatchRewardsModal({
|
||||
|
||||
<MatchPlayersList />
|
||||
|
||||
<button onClick={onClose} className="press-3d btn-gold w-full rounded-xl py-3 mt-6">
|
||||
<button onClick={onClose} className="relative press-3d btn-gold w-full rounded-xl py-3.5 mt-6 font-black text-base">
|
||||
{t("reward.continue")}
|
||||
</button>
|
||||
</motion.div>
|
||||
@@ -189,47 +248,35 @@ export function PostMatchRewardsModal({
|
||||
}
|
||||
|
||||
function RewardRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
positive,
|
||||
delay,
|
||||
icon, label, value, positive, delay,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
positive: boolean;
|
||||
delay: number;
|
||||
icon: React.ReactNode; label: string; value: string; positive: boolean; delay: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay }}
|
||||
className="glass rounded-xl px-4 py-2.5 flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-cream/80 text-sm">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"font-black tabular-nums " + (positive ? "text-teal-300" : "text-rose-300")
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-cream/80 text-sm">{icon}{label}</span>
|
||||
<span className={"font-black tabular-nums text-base " + (positive ? "text-teal-300" : "text-rose-300")}>
|
||||
{value}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({ text, delay }: { text: string; delay: number }) {
|
||||
function Banner({ text, delay, color }: { text: string; delay: number; color: "gold" | "teal" }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay }}
|
||||
className="mt-4 inline-flex items-center gap-2 btn-gold rounded-full px-5 py-1.5 font-black"
|
||||
initial={{ scale: 0, rotate: -5 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 260, delay }}
|
||||
className={
|
||||
"relative mt-4 inline-flex items-center gap-2 rounded-full px-5 py-1.5 font-black " +
|
||||
(color === "gold" ? "btn-gold" : "bg-teal-500/25 text-teal-200 border border-teal-500/40")
|
||||
}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
{text}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Check, Clock, Coins, Crown, Loader2, UserPlus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useOnlineStore } from "@/lib/online-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import {
|
||||
ACHIEVEMENTS,
|
||||
ACHIEVEMENT_CATEGORIES,
|
||||
TITLES,
|
||||
achievementProgress,
|
||||
} from "@/lib/online/gamification";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import type { AchievementCategoryId, PublicProfile } from "@/lib/online/types";
|
||||
import { GENDER_META, SOCIAL_PLATFORMS, hasSocials, socialUrl } from "@/lib/social";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { RankBadge } from "./RankBadge";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type SendState = "idle" | "sending" | "sent" | "error";
|
||||
|
||||
export function PublicProfileModal() {
|
||||
const userId = useUIStore((s) => s.viewProfileId);
|
||||
const close = useUIStore((s) => s.closeProfile);
|
||||
const { t, locale } = useI18n();
|
||||
const refreshFriends = useOnlineStore((s) => s.loadFriends);
|
||||
|
||||
const [profile, setProfile] = useState<PublicProfile | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tab, setTab] = useState<AchievementCategoryId>("victory");
|
||||
const [send, setSend] = useState<SendState>("idle");
|
||||
const [sendMsg, setSendMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
setProfile(null);
|
||||
setLoading(true);
|
||||
setSend("idle");
|
||||
setSendMsg("");
|
||||
setTab("victory");
|
||||
let alive = true;
|
||||
getService()
|
||||
.getPublicProfile(userId)
|
||||
.then((p) => {
|
||||
if (!alive) return;
|
||||
setProfile(p);
|
||||
if (p.requestSent) setSend("sent");
|
||||
})
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (!profile) return;
|
||||
setSend("sending");
|
||||
const res = await getService().addFriendById(profile.id);
|
||||
setSendMsg(locale === "fa" ? res.messageFa : res.messageEn);
|
||||
if (res.ok) {
|
||||
setSend("sent");
|
||||
refreshFriends();
|
||||
} else {
|
||||
setSend("error");
|
||||
}
|
||||
};
|
||||
|
||||
const titleDef = profile?.title ? TITLES.find((x) => x.id === profile.title) : null;
|
||||
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
|
||||
|
||||
const unlockedCount = profile
|
||||
? ACHIEVEMENTS.filter((a) => profile.unlocked.includes(a.id)).length
|
||||
: 0;
|
||||
const list = ACHIEVEMENTS.filter((a) => a.category === tab);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{userId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={close}
|
||||
className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center bg-navy-950/85 backdrop-blur-sm sm:p-5"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 60, scale: 0.96, opacity: 0 }}
|
||||
animate={{ y: 0, scale: 1, opacity: 1 }}
|
||||
exit={{ y: 40, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 240, damping: 24 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="glass w-full max-w-sm rounded-t-3xl sm:rounded-3xl p-5 max-h-[88vh] overflow-y-auto relative"
|
||||
>
|
||||
{/* close */}
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute top-3 ltr:right-3 rtl:left-3 z-10 glass rounded-full p-1.5 hover:bg-navy-800 transition"
|
||||
>
|
||||
<X className="size-4 text-cream/70" />
|
||||
</button>
|
||||
|
||||
{loading || !profile ? (
|
||||
<div className="h-64 grid place-items-center">
|
||||
<Loader2 className="size-8 text-gold-400 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* identity */}
|
||||
<div className="text-center relative">
|
||||
<div className="pointer-events-none absolute -top-6 left-1/2 -translate-x-1/2 size-32 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">
|
||||
<Avatar
|
||||
id={profile.avatar}
|
||||
image={profile.avatarImage}
|
||||
size={profile.avatarImage ? 80 : 56}
|
||||
/>
|
||||
</div>
|
||||
<span 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">
|
||||
{t("common.level")} {profile.level}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{titleName && <div className="mt-2 text-xs font-bold text-gold-300">{titleName}</div>}
|
||||
|
||||
<h2 className="mt-1.5 text-xl font-black text-cream flex items-center justify-center gap-1.5">
|
||||
{profile.displayName}
|
||||
{profile.gender && GENDER_META[profile.gender] && (
|
||||
<span
|
||||
className="text-lg"
|
||||
style={{ color: GENDER_META[profile.gender].color }}
|
||||
title={locale === "fa" ? GENDER_META[profile.gender].faLabel : GENDER_META[profile.gender].enLabel}
|
||||
>
|
||||
{GENDER_META[profile.gender].symbol}
|
||||
</span>
|
||||
)}
|
||||
{profile.plan === "pro" && (
|
||||
<Crown className="size-4 text-gold-400 fill-gold-500" />
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center">
|
||||
<RankBadge rating={profile.rating} showRating />
|
||||
</div>
|
||||
|
||||
{/* social links (only present when the owner allows it) */}
|
||||
{hasSocials(profile.socials) && (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center gap-2">
|
||||
{SOCIAL_PLATFORMS.map((p) => {
|
||||
const val = profile.socials?.[p.key]?.trim();
|
||||
if (!val) return null;
|
||||
return (
|
||||
<a
|
||||
key={p.key}
|
||||
href={socialUrl(p.key, val)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full bg-navy-900/70 gold-border px-2.5 py-1 text-[11px] font-semibold text-cream/85 hover:bg-navy-800 transition"
|
||||
>
|
||||
<span style={{ color: p.color }}>{p.icon}</span>
|
||||
{p.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* friend action */}
|
||||
{!profile.isYou && (
|
||||
<div className="mt-4">
|
||||
{profile.isFriend ? (
|
||||
<div className="rounded-xl bg-teal-500/15 border border-teal-500/30 text-teal-200 py-2.5 text-center text-sm font-bold flex items-center justify-center gap-1.5">
|
||||
<Check className="size-4" />
|
||||
{t("profile.alreadyFriend")}
|
||||
</div>
|
||||
) : send === "sent" ? (
|
||||
<div className="rounded-xl bg-navy-900/70 gold-border text-gold-300 py-2.5 text-center text-sm font-bold flex items-center justify-center gap-1.5">
|
||||
<Clock className="size-4" />
|
||||
{t("profile.requestSent")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={sendRequest}
|
||||
disabled={send === "sending"}
|
||||
className="press-3d btn-gold w-full rounded-xl py-3 font-black flex items-center justify-center gap-2 disabled:opacity-70"
|
||||
>
|
||||
{send === "sending" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="size-4" />
|
||||
)}
|
||||
{t("profile.sendRequest")}
|
||||
</button>
|
||||
{send === "error" && sendMsg && (
|
||||
<p className="mt-2 text-rose-300 text-xs text-center">{sendMsg}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* stats */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<Stat label={t("profile.games")} value={profile.stats.games} />
|
||||
<Stat label={t("profile.wins")} value={profile.stats.wins} />
|
||||
<Stat
|
||||
label={t("profile.winrate")}
|
||||
value={`${profile.stats.games > 0 ? Math.round((profile.stats.wins / profile.stats.games) * 100) : 0}%`}
|
||||
/>
|
||||
<Stat label={t("profile.kots")} value={profile.stats.kotsFor} />
|
||||
<Stat label={t("profile.streak")} value={profile.stats.bestWinStreak} />
|
||||
<Stat label={t("common.rating")} value={Math.round(profile.rating)} />
|
||||
</div>
|
||||
|
||||
{/* achievement board */}
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-cream/80">{t("achv.title")}</h3>
|
||||
<span className="text-xs text-gold-300 font-bold">
|
||||
{unlockedCount}/{ACHIEVEMENTS.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-2 mt-2 -mx-1 px-1">
|
||||
{ACHIEVEMENT_CATEGORIES.map((c) => {
|
||||
const active = tab === c.id;
|
||||
const done = ACHIEVEMENTS.filter(
|
||||
(a) => a.category === c.id && profile.unlocked.includes(a.id)
|
||||
).length;
|
||||
const total = ACHIEVEMENTS.filter((a) => a.category === c.id).length;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setTab(c.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1.5 text-xs font-bold transition flex items-center gap-1",
|
||||
active ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70"
|
||||
)}
|
||||
>
|
||||
<span>{c.icon}</span>
|
||||
<span className={cn("text-[10px]", active ? "text-[#2a1f04]/70" : "text-cream/40")}>
|
||||
{done}/{total}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mt-1">
|
||||
{list.map((a) => {
|
||||
const unlocked = profile.unlocked.includes(a.id);
|
||||
const prog = achievementProgress(a, profile.stats, profile.rating, profile.level);
|
||||
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
title={locale === "fa" ? a.nameFa : a.nameEn}
|
||||
className={cn(
|
||||
"aspect-square rounded-xl flex flex-col items-center justify-center gap-1 border p-1 relative",
|
||||
unlocked
|
||||
? "bg-gold-500/15 border-gold-500/45"
|
||||
: "bg-navy-900/50 border-navy-700/50"
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-2xl", !unlocked && "grayscale opacity-40")}>
|
||||
{a.icon}
|
||||
</span>
|
||||
{unlocked ? (
|
||||
<Check className="size-3 text-gold-400" />
|
||||
) : (
|
||||
<span className="text-[8px] text-cream/40 tabular-nums">{pct}%</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-center gap-1 text-[11px] text-cream/45">
|
||||
<Coins className="size-3 text-gold-400/70" />
|
||||
{t("profile.memberSince")}{" "}
|
||||
{new Date(profile.createdAt).toLocaleDateString(locale === "fa" ? "fa-IR" : "en-US")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="bg-navy-900/60 rounded-xl py-2.5 text-center">
|
||||
<div className="text-lg font-black gold-text tabular-nums">{value}</div>
|
||||
<div className="text-[10px] text-cream/55 mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,30 +3,37 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useUIStore, type Screen } from "@/lib/ui-store";
|
||||
import { LevelXpBar } from "./LevelXpBar";
|
||||
|
||||
export function ScreenHeader({
|
||||
title,
|
||||
back = "home",
|
||||
right,
|
||||
showXp = true,
|
||||
}: {
|
||||
title: string;
|
||||
back?: Screen;
|
||||
right?: React.ReactNode;
|
||||
/** Show the persistent level + XP chip beneath the header (default on). */
|
||||
showXp?: boolean;
|
||||
}) {
|
||||
const navBack = useUIStore((s) => s.back);
|
||||
const { locale } = useI18n();
|
||||
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 mb-5">
|
||||
<button
|
||||
onClick={() => navBack(back)}
|
||||
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
|
||||
>
|
||||
<Chevron className="size-5 text-cream/80" />
|
||||
</button>
|
||||
<h1 className="gold-text text-2xl font-black">{title}</h1>
|
||||
<div className="min-w-10 flex justify-end">{right}</div>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<button
|
||||
onClick={() => navBack(back)}
|
||||
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
|
||||
>
|
||||
<Chevron className="size-5 text-cream/80" />
|
||||
</button>
|
||||
<h1 className="gold-text text-2xl font-black">{title}</h1>
|
||||
<div className="min-w-10 flex justify-end">{right}</div>
|
||||
</div>
|
||||
{showXp && <LevelXpBar className="w-full mb-5" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,53 @@ function Face({
|
||||
);
|
||||
}
|
||||
|
||||
/** A Persian-text "stamp" sticker (rounded badge + bold phrase). */
|
||||
function Stamp({
|
||||
text,
|
||||
bg,
|
||||
ring,
|
||||
fg = "#ffffff",
|
||||
rot = 0,
|
||||
fs = 21,
|
||||
}: {
|
||||
text: string;
|
||||
bg: string;
|
||||
ring: string;
|
||||
fg?: string;
|
||||
rot?: number;
|
||||
fs?: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<rect x="4" y="26" width="92" height="48" rx="14" fill={bg} stroke={ring} strokeWidth="3" transform={`rotate(${rot} 50 50)`} />
|
||||
<text
|
||||
x="50"
|
||||
y="58"
|
||||
textAnchor="middle"
|
||||
fontFamily="Vazirmatn, sans-serif"
|
||||
fontWeight="900"
|
||||
fontSize={fs}
|
||||
fill={fg}
|
||||
transform={`rotate(${rot} 50 50)`}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A mini playing card (court/ace) sticker. */
|
||||
function CourtCard({ corner, center, color = "#1b1b1b" }: { corner: string; center: string; color?: string }) {
|
||||
return (
|
||||
<>
|
||||
<rect x="24" y="12" width="52" height="76" rx="8" fill="#fffdf7" stroke="#caa84a" strokeWidth="2.5" />
|
||||
<text x="34" y="31" textAnchor="middle" fontSize="13" fontWeight="800" fill={color}>{corner}</text>
|
||||
<text x="50" y="60" textAnchor="middle" fontSize="30" fill={color}>{center}</text>
|
||||
<text x="66" y="82" textAnchor="middle" fontSize="13" fontWeight="800" fill={color} transform="rotate(180 66 76)">{corner}</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const STICKERS: Record<string, React.ReactNode> = {
|
||||
/* ----------------------------- faces ----------------------------- */
|
||||
happy: (
|
||||
@@ -262,6 +309,74 @@ const STICKERS: Record<string, React.ReactNode> = {
|
||||
<text x="50" y="59" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="26" fill="#daffe4" transform="rotate(6 50 50)">بردیم!</text>
|
||||
</>
|
||||
),
|
||||
|
||||
/* ===================== کلکل / banter (text stamps) ===================== */
|
||||
sukhti: <Stamp text="سوختی!" bg="#7a0f1a" ring="#ff6b81" fg="#ffd9de" rot={-8} fs={26} />,
|
||||
"yad-begir": <Stamp text="یاد بگیر" bg="#3a2a4d" ring="#c77dff" fg="#e7d4ff" fs={22} />,
|
||||
"nobate-man": <Stamp text="نوبت منه" bg="#0d4d6b" ring="#5ac8e0" fg="#d8f4ff" fs={22} />,
|
||||
"naz-nakon": <Stamp text="ناز نکن" bg="#5a3c0a" ring="#ffd76a" fg="#ffe9a8" fs={23} />,
|
||||
kojai: <Stamp text="کجایی؟!" bg="#4d2a0a" ring="#ffae5a" fg="#ffe2c2" rot={-6} fs={24} />,
|
||||
"hool-nasho": <Stamp text="هول نشو" bg="#0d6b5e" ring="#2dd4bf" fg="#d8fff5" fs={22} />,
|
||||
"didi-goftam": <Stamp text="دیدی گفتم؟" bg="#3a0a2a" ring="#ff5fa2" fg="#ffd6ea" fs={19} />,
|
||||
"bendaz-dige": <Stamp text="بنداز دیگه!" bg="#1f2b4d" ring="#6aa6ff" fg="#dbe8ff" rot={5} fs={20} />,
|
||||
"nakon-eddea": <Stamp text="ادعا نکن" bg="#4d0f1a" ring="#ff6b81" fg="#ffd9de" fs={22} />,
|
||||
"shans-avordi": <Stamp text="شانس آوردی" bg="#3a2a04" ring="#e6c659" fg="#fff0c2" fs={18} />,
|
||||
"biya-bebin": <Stamp text="بیا ببین!" bg="#0a3a4d" ring="#5ac8e0" fg="#d8f4ff" rot={-5} fs={23} />,
|
||||
"kart-nadari": <Stamp text="کارت نداری" bg="#2a1a4d" ring="#a98bff" fg="#e7d4ff" fs={19} />,
|
||||
|
||||
/* ===================== Persian trends / praise ===================== */
|
||||
eyval: <Stamp text="ایول!" bg="#136f3a" ring="#7fe3a0" fg="#daffe4" rot={-7} fs={26} />,
|
||||
torkundi: <Stamp text="ترکوندی" bg="#7a3a00" ring="#ff9d3a" fg="#ffe0bf" fs={22} />,
|
||||
"gol-kashti": <Stamp text="گل کاشتی" bg="#0d5a3a" ring="#4fe39a" fg="#d6ffe8" fs={21} />,
|
||||
"harf-nadari": <Stamp text="حرف نداری" bg="#3a2a04" ring="#e6c659" fg="#fff0c2" fs={20} />,
|
||||
"damet-garm-2": <Stamp text="دمت گرم" bg="#0d6b5e" ring="#2dd4bf" fg="#d8fff5" rot={4} fs={22} />,
|
||||
"nush-jan": <Stamp text="نوش جان" bg="#5a3c0a" ring="#ffd76a" fg="#ffe9a8" fs={22} />,
|
||||
"be-be": <Stamp text="بهبه!" bg="#3a0a2a" ring="#ff5fa2" fg="#ffd6ea" rot={-6} fs={26} />,
|
||||
ghorbunet: <Stamp text="قربونت" bg="#4d1f3a" ring="#ff8fc2" fg="#ffd9ec" fs={22} />,
|
||||
|
||||
/* ===================== Victory / closers ===================== */
|
||||
"jam-kon": <Stamp text="جمع کن!" bg="#4d0f1a" ring="#ff6b81" fg="#ffd9de" rot={7} fs={23} />,
|
||||
"kish-mat": <Stamp text="کیش و مات" bg="#13314d" ring="#d4af37" fg="#ffe488" fs={20} />,
|
||||
khdahafez: <Stamp text="خداحافظ" bg="#1f2b4d" ring="#8fb4ff" fg="#dbe8ff" rot={-5} fs={21} />,
|
||||
|
||||
/* ===================== Court cards (Hokm) ===================== */
|
||||
"tak-khal": <CourtCard corner="A" center="♠" color="#1b1b1b" />,
|
||||
"as-del": <CourtCard corner="A" center="♥" color="#d11a2a" />,
|
||||
"shah-khesht": <CourtCard corner="K" center="♦" color="#d11a2a" />,
|
||||
"bibi-gesht": <CourtCard corner="Q" center="♣" color="#1b1b1b" />,
|
||||
|
||||
/* ===================== Extra emotions ===================== */
|
||||
laugh: (
|
||||
<Face bg1="#ffe680" bg2="#f5b301">
|
||||
<path d="M30 42 q6 6 12 0 M58 42 q6 6 12 0" fill="none" stroke="#3a2a00" strokeWidth="4" strokeLinecap="round" />
|
||||
<path d="M30 58 Q50 84 70 58 Z" fill="#7a0b16" />
|
||||
<path d="M30 58 Q50 84 70 58" fill="none" stroke="#3a2a00" strokeWidth="4" strokeLinejoin="round" />
|
||||
<path d="M24 64 q-6 6 -4 12 M76 64 q6 6 4 12" stroke="#5aa6e0" strokeWidth="3" fill="none" strokeLinecap="round" />
|
||||
</Face>
|
||||
),
|
||||
shocked: (
|
||||
<Face bg1="#bfe3ff" bg2="#5aa6e0">
|
||||
<circle cx="36" cy="44" r="7" fill="#fff" stroke="#13314d" strokeWidth="2.5" />
|
||||
<circle cx="64" cy="44" r="7" fill="#fff" stroke="#13314d" strokeWidth="2.5" />
|
||||
<circle cx="36" cy="44" r="3" fill="#13314d" />
|
||||
<circle cx="64" cy="44" r="3" fill="#13314d" />
|
||||
<ellipse cx="50" cy="68" rx="8" ry="11" fill="#13314d" />
|
||||
</Face>
|
||||
),
|
||||
cry: (
|
||||
<Face bg1="#bfe3ff" bg2="#5aa6e0">
|
||||
<path d="M30 44 q6 -5 12 0 M58 44 q6 -5 12 0" fill="none" stroke="#13314d" strokeWidth="4" strokeLinecap="round" />
|
||||
<path d="M34 72 Q50 60 66 72" fill="none" stroke="#13314d" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M34 52 q-3 12 0 20 q3 -8 0 -20" fill="#2f8fd6" />
|
||||
<path d="M66 52 q-3 12 0 20 q3 -8 0 -20" fill="#2f8fd6" />
|
||||
</Face>
|
||||
),
|
||||
smug: (
|
||||
<Face bg1="#ffe680" bg2="#f5b301">
|
||||
<path d="M30 44 h14 M56 44 h14" stroke="#3a2a00" strokeWidth="4" strokeLinecap="round" />
|
||||
<path d="M34 62 Q50 70 64 58" fill="none" stroke="#3a2a00" strokeWidth="5" strokeLinecap="round" />
|
||||
</Face>
|
||||
),
|
||||
};
|
||||
|
||||
export const STICKER_IDS = Object.keys(STICKERS);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useNotifStore } from "@/lib/notification-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MAX_LEVEL, xpNeededForLevel } from "@/lib/online/gamification";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
export function TopBar() {
|
||||
@@ -15,6 +16,10 @@ export function TopBar() {
|
||||
const { t } = useI18n();
|
||||
if (!profile) return null;
|
||||
|
||||
const maxed = profile.level >= MAX_LEVEL;
|
||||
const xpNeed = xpNeededForLevel(profile.level);
|
||||
const xpPct = maxed ? 100 : Math.min(100, Math.max(0, Math.round((profile.xp / xpNeed) * 100)));
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
@@ -24,13 +29,20 @@ export function TopBar() {
|
||||
<span className="relative size-9 rounded-full bg-navy-900 gold-border flex items-center justify-center overflow-hidden">
|
||||
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 36 : 26} />
|
||||
</span>
|
||||
<span className="text-start leading-tight">
|
||||
<span className="text-start leading-tight min-w-[96px]">
|
||||
<span className="flex items-center gap-1 text-sm font-bold text-cream max-w-28 truncate">
|
||||
{profile.displayName}
|
||||
{profile.plan === "pro" && <Crown className="size-3 text-gold-400 fill-gold-500 shrink-0" />}
|
||||
</span>
|
||||
<span className="block text-[10px] text-gold-400/80">
|
||||
{t("common.level")} {profile.level}
|
||||
<span className="flex items-center justify-between text-[10px] text-gold-400/80 mt-0.5">
|
||||
<span>{t("common.level")} {profile.level}</span>
|
||||
<span className="text-cream/40 tabular-nums">{maxed ? "MAX" : `${xpPct}%`}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block h-1 w-full rounded-full bg-navy-900 overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-full bg-gradient-to-r from-gold-500 to-gold-300"
|
||||
style={{ width: `${xpPct}%` }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Star } from "lucide-react";
|
||||
import { xpNeededForLevel } from "@/lib/online/gamification";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export function XpBar({ level, xp }: { level: number; xp: number }) {
|
||||
export function XpBar({
|
||||
level,
|
||||
xp,
|
||||
showBadge = false,
|
||||
}: {
|
||||
level: number;
|
||||
xp: number;
|
||||
showBadge?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const need = xpNeededForLevel(level);
|
||||
const pct = Math.min(100, Math.round((xp / need) * 100));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between text-[10px] text-cream/55 mb-1">
|
||||
<span>
|
||||
<div className="flex items-center justify-between text-[11px] mb-1.5">
|
||||
<span className="flex items-center gap-1 font-bold text-gold-300">
|
||||
{showBadge && (
|
||||
<span className="inline-flex items-center justify-center size-4 rounded-full bg-gold-500/20">
|
||||
<Star className="size-2.5 text-gold-400" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
{t("common.level")} {level}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{xp} / {need} XP
|
||||
<span className="tabular-nums text-cream/60">
|
||||
{xp.toLocaleString()} / {need.toLocaleString()} XP
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 rounded-full bg-navy-900/80 overflow-hidden gold-border">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: "linear-gradient(90deg, var(--gold-500), var(--gold-300))",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-full bg-navy-900/80 overflow-hidden gold-border",
|
||||
showBadge ? "h-3.5" : "h-2.5"
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className="h-full rounded-full relative"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.9, ease: "easeOut" }}
|
||||
style={{ background: "linear-gradient(90deg, var(--gold-600), var(--gold-400), var(--gold-300))" }}
|
||||
>
|
||||
{/* glossy sheen */}
|
||||
<span className="absolute inset-x-0 top-0 h-1/2 rounded-full bg-white/25" />
|
||||
</motion.div>
|
||||
{/* animated shimmer sweep */}
|
||||
{pct > 0 && pct < 100 && (
|
||||
<motion.span
|
||||
className="absolute top-0 h-full w-8 bg-white/30 blur-sm"
|
||||
initial={{ left: "-10%" }}
|
||||
animate={{ left: "110%" }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, repeatDelay: 1.4, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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