100 gated gifts (level/rating-locked) + requirement system
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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Check, Coins, Sparkles, X } from "lucide-react";
|
||||
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";
|
||||
@@ -94,6 +94,13 @@ export function ShopScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
@@ -179,7 +186,7 @@ export function ShopScreen() {
|
||||
<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)} />
|
||||
<ItemCard key={item.id} item={item} owned={owns(item)} reqLabel={lockLabel(item)} onOpen={() => setDetail(item)} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
@@ -192,6 +199,7 @@ export function ShopScreen() {
|
||||
item={detail}
|
||||
owned={owns(detail)}
|
||||
coins={profile.coins}
|
||||
reqLabel={lockLabel(detail)}
|
||||
onBuy={() => buy(detail)}
|
||||
onClose={() => setDetail(null)}
|
||||
/>
|
||||
@@ -212,10 +220,11 @@ function Section({ title, hint, children }: { title: string; hint?: string; chil
|
||||
);
|
||||
}
|
||||
|
||||
function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) {
|
||||
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 }}
|
||||
@@ -235,7 +244,7 @@ function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onO
|
||||
<Check className="size-3.5" strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
<div className="h-12 flex items-center justify-center">
|
||||
<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">
|
||||
@@ -254,11 +263,16 @@ function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onO
|
||||
<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 ? "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" />
|
||||
@@ -274,18 +288,21 @@ 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 (
|
||||
@@ -346,11 +363,13 @@ function DetailSheet({
|
||||
{/* buy */}
|
||||
<button
|
||||
onClick={onBuy}
|
||||
disabled={owned || !canAfford}
|
||||
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"
|
||||
@@ -360,6 +379,10 @@ function DetailSheet({
|
||||
<>
|
||||
<Check className="size-4" /> {t("shop.owned")}
|
||||
</>
|
||||
) : locked ? (
|
||||
<>
|
||||
<Lock className="size-4" /> {reqLabel}
|
||||
</>
|
||||
) : canAfford ? (
|
||||
<>
|
||||
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
|
||||
|
||||
Reference in New Issue
Block a user