38ac8b06d1
Adds ~100 new purchasable gifts that are LOCKED until a level/rating gate is met, then buyable with coins — value scales with the gate: - 45 gift avatars (types.ts), 35 gift titles + 20 gift card backs (gamification.ts), all reusing existing renderers. Tier (1-5) encoded in the id (-t<n>-). - Gate model: GIFT_TIERS (shared) → reqLevel/reqRating on AvatarDef/TitleDef/ CardBackDef + ShopItem. Tiers: t1 free, t2 Lv10, t3 Lv20, t4 Lv35, t5 Rating1700. - Shop UI: locked cards dim + show the requirement (Lock + "Level 20"), buy disabled until met; mock buyItem enforces it offline. - Server enforces generically — ProfileService parses the tier from the id and checks the player's level/rating (no 100-entry mirror). Mirrors GIFT_TIERS. - i18n shop.reqLevel/reqRating (fa+en). Verified: tsc + sim + next build + dotnet build all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import { Check, Coins, Lock, Sparkles, X } from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||
import { Sticker } from "@/components/online/Sticker";
|
||
import { CoinsPill } from "@/components/online/CoinsPill";
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Requirement gate: returns a label while LOCKED, else null.
|
||
const lockLabel = (item: ShopItem): string | null => {
|
||
if (item.reqLevel && profile.level < item.reqLevel) return `${t("shop.reqLevel")} ${item.reqLevel}`;
|
||
if (item.reqRating && profile.rating < item.reqRating) return `${t("shop.reqRating")} ${item.reqRating}`;
|
||
return null;
|
||
};
|
||
|
||
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={<CoinsPill />} />
|
||
|
||
{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)} reqLabel={lockLabel(item)} onOpen={() => setDetail(item)} />
|
||
))}
|
||
</div>
|
||
</Section>
|
||
);
|
||
})}
|
||
|
||
<AnimatePresence>
|
||
{detail && (
|
||
<DetailSheet
|
||
item={detail}
|
||
owned={owns(detail)}
|
||
coins={profile.coins}
|
||
reqLabel={lockLabel(detail)}
|
||
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, reqLabel, onOpen }: { item: ShopItem; owned: boolean; reqLabel: string | null; onOpen: () => void }) {
|
||
const { locale, t } = useI18n();
|
||
const count = item.contents?.length;
|
||
const luxury = item.price >= 2000; // premium "luxury" tier
|
||
const locked = !owned && !!reqLabel;
|
||
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={cn("h-12 flex items-center justify-center", locked && "opacity-40 grayscale")}>
|
||
<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" : locked ? "bg-navy-900/70 text-cream/55" : "btn-gold"
|
||
)}
|
||
>
|
||
{owned ? (
|
||
<Check className="size-3.5" />
|
||
) : locked ? (
|
||
<>
|
||
<Lock className="size-3" />
|
||
<span className="text-[10px] truncate">{reqLabel}</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Coins className="size-3.5" />
|
||
{item.price.toLocaleString()}
|
||
</>
|
||
)}
|
||
</span>
|
||
</motion.button>
|
||
);
|
||
}
|
||
|
||
function DetailSheet({
|
||
item,
|
||
owned,
|
||
coins,
|
||
reqLabel,
|
||
onBuy,
|
||
onClose,
|
||
}: {
|
||
item: ShopItem;
|
||
owned: boolean;
|
||
coins: number;
|
||
reqLabel: string | null;
|
||
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 locked = !owned && !!reqLabel;
|
||
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 || locked || !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"
|
||
: locked
|
||
? "bg-navy-900/60 text-cream/60"
|
||
: canAfford
|
||
? "btn-gold"
|
||
: "bg-navy-900/60 text-rose-300"
|
||
)}
|
||
>
|
||
{owned ? (
|
||
<>
|
||
<Check className="size-4" /> {t("shop.owned")}
|
||
</>
|
||
) : locked ? (
|
||
<>
|
||
<Lock className="size-4" /> {reqLabel}
|
||
</>
|
||
) : canAfford ? (
|
||
<>
|
||
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
|
||
</>
|
||
) : (
|
||
t("lobby.needCoins")
|
||
)}
|
||
</button>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|