Files
HokmPlay/src/components/screens/ShopScreen.tsx
T
soroush.asadi cb27a16dc1 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>
2026-06-06 18:39:24 +03:30

382 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Check, Coins, Sparkles, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { Sticker } from "@/components/online/Sticker";
import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { sound } from "@/lib/sound";
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";
/** The product artwork, used on the card and (bigger) in the detail sheet. */
function Preview({ item, size }: { item: ShopItem; size: number }) {
switch (item.kind) {
case "stickerpack":
return <Sticker id={item.preview} size={size} />;
case "cardfront":
return (
<span
className="rounded-md border flex items-center justify-center text-slate-900 font-black"
style={{
width: size * 0.72,
height: size,
fontSize: size * 0.4,
background: `linear-gradient(160deg, #ffffff, ${item.preview})`,
borderColor: "rgba(0,0,0,0.18)",
}}
>
</span>
);
case "cardback": {
const back = cardBackById(item.id);
return (
<span
className="rounded-md border grid place-items-center"
style={{
width: size * 0.72,
height: size,
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>;
}
}
export function ShopScreen() {
const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile);
const setProfile = useSessionStore((s) => s.setProfile);
const [items, setItems] = useState<ShopItem[]>([]);
const [msg, setMsg] = useState("");
const [detail, setDetail] = useState<ShopItem | null>(null);
useEffect(() => {
getService().getShopItems().then(setItems);
}, []);
if (!profile) return null;
const owns = (item: ShopItem) => {
switch (item.kind) {
case "avatar": return profile.ownedAvatars.includes(item.id);
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);
}
};
const buy = async (item: ShopItem) => {
const before = profile;
const res = await getService().buyItem(item.id);
if (res.ok && res.profile) {
const after = res.profile;
setProfile(after);
sound.play("purchase");
setDetail(null);
// newly-unlocked achievements (e.g. an XP pack crossing a level milestone)
const newAch: AchievementUnlock[] = after.unlocked
.filter((id) => !before.unlocked.includes(id))
.map((id) => achievementById(id))
.filter((d): d is NonNullable<typeof d> => !!d)
.map((d) => ({ id: d.id, nameFa: d.nameFa, nameEn: d.nameEn, icon: d.icon, coinReward: d.coinReward }));
if (item.kind === "xp") {
celebrate({
variant: "xp",
icon: "⚡",
title: locale === "fa" ? "امتیاز تجربه" : "Experience",
xpGained: item.xp ?? 0,
levelBefore: before.level,
levelAfter: after.level,
achievements: newAch.length ? newAch : undefined,
});
} else {
celebrate({
variant: "purchase",
// avatar/reaction previews are emojis; others fall back to the default glyph
icon: item.kind === "avatar" || item.kind === "reactionpack" ? item.preview : undefined,
title: locale === "fa" ? item.nameFa : item.nameEn,
achievements: newAch.length ? newAch : undefined,
});
}
} else {
setMsg(locale === "fa" ? res.messageFa : res.messageEn);
setTimeout(() => setMsg(""), 1800);
}
};
const sections: { title: string; kind: ShopItem["kind"]; hint?: string }[] = [
{ title: t("shop.avatars"), kind: "avatar" },
{ title: t("shop.cardfronts"), kind: "cardfront" },
{ 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") },
];
return (
<ScreenShell>
<ScreenHeader
title={t("shop.title")}
right={
<span className="glass rounded-full px-3 py-1.5 text-xs font-bold text-gold-300 flex items-center gap-1">
<Coins className="size-3.5 text-gold-400" />
{profile.coins.toLocaleString()}
</span>
}
/>
{msg && (
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
)}
{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;
return (
<Section key={sec.kind} title={sec.title} hint={sec.hint}>
<div className="grid grid-cols-3 gap-3">
{list.map((item) => (
<ItemCard key={item.id} item={item} owned={owns(item)} onOpen={() => setDetail(item)} />
))}
</div>
</Section>
);
})}
<AnimatePresence>
{detail && (
<DetailSheet
item={detail}
owned={owns(detail)}
coins={profile.coins}
onBuy={() => buy(detail)}
onClose={() => setDetail(null)}
/>
)}
</AnimatePresence>
</ScreenShell>
);
}
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<div className="mb-5">
<h3 className="text-sm font-bold text-cream/80 mb-1">{title}</h3>
{hint && <p className="text-[11px] text-cream/45 mb-2.5">{hint}</p>}
{!hint && <div className="mb-1" />}
{children}
</div>
);
}
function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) {
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={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} />
</span>
)}
<div className="h-12 flex items-center justify-center">
<Preview item={item} size={44} />
</div>
<span className="text-[11px] font-semibold text-cream/90 truncate max-w-full">
{locale === "fa" ? item.nameFa : item.nameEn}
</span>
{/* quick detail hint */}
<span className="text-[9px] text-cream/45 leading-none">
{item.kind === "xp"
? `+${item.xp} XP`
: count != null
? locale === "fa"
? `${count} مورد`
: `${count} items`
: " "}
</span>
<span
className={cn(
"w-full rounded-lg py-1.5 text-xs font-bold flex items-center justify-center gap-1",
owned ? "bg-navy-900/60 text-teal-300" : "btn-gold"
)}
>
{owned ? (
<Check className="size-3.5" />
) : (
<>
<Coins className="size-3.5" />
{item.price.toLocaleString()}
</>
)}
</span>
</motion.button>
);
}
function DetailSheet({
item,
owned,
coins,
onBuy,
onClose,
}: {
item: ShopItem;
owned: boolean;
coins: number;
onBuy: () => void;
onClose: () => void;
}) {
const { t, locale } = useI18n();
const name = locale === "fa" ? item.nameFa : item.nameEn;
const desc = locale === "fa" ? item.descFa : item.descEn;
const canAfford = coins >= item.price;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-[70] flex items-end sm:items-center justify-center bg-navy-950/80 backdrop-blur-sm p-4"
>
<motion.div
initial={{ y: 40, 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 rounded-3xl p-6 w-full max-w-sm text-center relative"
>
<button onClick={onClose} className="absolute top-3 ltr:right-3 rtl:left-3 grid size-9 place-items-center rounded-full hover:bg-navy-800/80 transition">
<X className="size-4 text-cream/60" />
</button>
<div className="mx-auto size-24 rounded-2xl bg-navy-900/70 gold-border grid place-items-center">
<Preview item={item} size={item.kind === "xp" || item.kind === "avatar" || item.kind === "reactionpack" ? 64 : 72} />
</div>
<h2 className="gold-text text-xl font-black mt-3">{name}</h2>
{desc && <p className="text-cream/65 text-sm mt-1">{desc}</p>}
{/* what's included */}
{item.contents && item.contents.length > 0 && (
<div className="mt-4 text-start">
<div className="text-[11px] font-bold text-cream/55 mb-2">
{t("shop.includes")} ({item.contents.length})
</div>
<div className="flex flex-wrap gap-2 justify-center">
{item.kind === "stickerpack"
? item.contents.map((s) => (
<div key={s} className="rounded-xl bg-navy-900/60 p-1.5">
<Sticker id={s} size={40} />
</div>
))
: item.contents.map((e, i) => (
<span key={i} className="rounded-xl bg-navy-900/60 size-11 grid place-items-center text-2xl">
{e}
</span>
))}
</div>
</div>
)}
{item.kind === "xp" && (
<div className="mt-4 inline-flex items-center gap-2 btn-gold rounded-full px-5 py-2 font-black">
<Sparkles className="size-4" />
+{item.xp?.toLocaleString()} XP
</div>
)}
{/* buy */}
<button
onClick={onBuy}
disabled={owned || !canAfford}
className={cn(
"press-3d w-full rounded-2xl py-3.5 mt-6 font-black flex items-center justify-center gap-2",
owned
? "bg-navy-900/60 text-teal-300"
: canAfford
? "btn-gold"
: "bg-navy-900/60 text-rose-300"
)}
>
{owned ? (
<>
<Check className="size-4" /> {t("shop.owned")}
</>
) : canAfford ? (
<>
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
</>
) : (
t("lobby.needCoins")
)}
</button>
</motion.div>
</motion.div>
);
}