Build Hokm card game: offline vs-AI + online social/gamification (mock backend)

- Pure-TS Hokm engine (deal, hakem, trump, tricks, scoring, Kot) + AI bots
- Persian-luxury RTL UI (Next 16 / React 19 / Tailwind v4 / Framer Motion / Zustand)
- Online platform behind OnlineService seam (mock now, .NET SignalR later):
  auth (phone OTP + email/Google), profiles, friends, private rooms with
  partner pick, ranked matchmaking, leaderboard, shop
- Gamification: ranks/leagues, coins, XP/levels, daily rewards, achievements
- i18n fa/en, PWA manifest, engine + gamification sims

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 10:11:00 +03:30
parent dff1a34f95
commit e2d0a602b6
41 changed files with 5766 additions and 93 deletions
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { Check, Coins } from "lucide-react";
import { useEffect, useState } from "react";
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 { ShopItem } from "@/lib/online/types";
import { cn } from "@/lib/cn";
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("");
useEffect(() => {
getService().getShopItems().then(setItems);
}, []);
if (!profile) return null;
const owns = (item: ShopItem) =>
item.kind === "avatar"
? profile.ownedAvatars.includes(item.id)
: profile.ownedThemes.includes(item.id);
const buy = async (item: ShopItem) => {
const res = await getService().buyItem(item.id);
if (res.ok && res.profile) {
setProfile(res.profile);
} else {
setMsg(locale === "fa" ? res.messageFa : res.messageEn);
setTimeout(() => setMsg(""), 1800);
}
};
const avatars = items.filter((i) => i.kind === "avatar");
const themes = items.filter((i) => i.kind === "theme");
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>
)}
<Section title={t("shop.avatars")}>
<div className="grid grid-cols-3 gap-3">
{avatars.map((item) => (
<ItemCard key={item.id} item={item} owned={owns(item)} onBuy={() => buy(item)} preview={<span className="text-4xl">{item.preview}</span>} />
))}
</div>
</Section>
<Section title={t("shop.themes")}>
<div className="grid grid-cols-3 gap-3">
{themes.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={owns(item)}
onBuy={() => buy(item)}
preview={
<span
className="size-10 rounded-xl border border-white/20"
style={{ background: item.preview }}
/>
}
/>
))}
</div>
</Section>
</ScreenShell>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-5">
<h3 className="text-sm font-bold text-cream/80 mb-3">{title}</h3>
{children}
</div>
);
}
function ItemCard({
item,
owned,
onBuy,
preview,
}: {
item: ShopItem;
owned: boolean;
onBuy: () => void;
preview: React.ReactNode;
}) {
const { t } = useI18n();
return (
<div className="glass rounded-2xl p-3 flex flex-col items-center gap-2">
<div className="h-12 flex items-center justify-center">{preview}</div>
<button
disabled={owned}
onClick={onBuy}
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" />
{t("shop.owned")}
</>
) : (
<>
<Coins className="size-3.5" />
{item.price.toLocaleString()}
</>
)}
</button>
</div>
);
}