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:
@@ -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