This commit is contained in:
2026-09-24 12:30:58 +03:30
parent 15d110d824
commit 267cf5fca0
3 changed files with 164 additions and 16 deletions

View File

@@ -12,11 +12,12 @@ import { createClient, ApiError } from "../api";
import type {
Grade as ApiGrade, HomeResponse, HomeUnit, HomeNode, NodeStatus,
LessonIntro, Question as ApiQuestion, AnswerResult, Plan as ApiPlan,
Character as ApiCharacter,
} from "../api";
// ─── کلاینتِ API — تنها جایی که با بک‌اند حرف می‌زنیم ─────────────────────────
const api = createClient({
baseUrl: (import.meta as { env?: Record<string, string> }).env?.VITE_API_URL ?? "/api",
baseUrl: (import.meta as { env?: Record<string, string> }).env?.VITE_API_BASE_URL ?? "https://api.math.tadrisino2.ir/api",
});
function getOrCreateDeviceId(): string {
@@ -282,6 +283,16 @@ const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
};
/** اگه slugِ کاراکترِ سرور با یکی از سه‌یار یکی باشه، از طرحِ خودمون (انیمیشن‌دار) استفاده می‌کنیم؛
* وگرنه یه کارتِ عمومی نشون می‌دیم (پیشنهاد: به بک‌اند بگید slugها رو sigma/delta/pi بذاره). */
function seyarSlugMatch(slug: string): SeyarChar | null {
const s = slug.toLowerCase();
if (s.includes("sigma") || s.includes("سیگما")) return "sigma";
if (s.includes("delta") || s.includes("دلتا")) return "delta";
if (s.includes("pi") || s.includes("پی")) return "pi";
return null;
}
function SeyarMascot({ char, mood = "idle", size = 100, bob = false }: { char: SeyarChar; mood?: SeyarMood; size?: number; bob?: boolean }) {
return (
<motion.div
@@ -300,7 +311,7 @@ function SeyarMascot({ char, mood = "idle", size = 100, bob = false }: { char: S
// ─── Types ────────────────────────────────────────────────────────────────────
type AppFlow = "splash" | "onboarding" | "login" | "profile" | "main";
type MainScreen = "home" | "topics" | "skillmap" | "lessons" | "lesson" | "question" | "askai" | "subscription" | "notifications" | "leaderboard";
type MainScreen = "home" | "topics" | "skillmap" | "lessons" | "lesson" | "question" | "askai" | "subscription" | "notifications" | "leaderboard" | "characters";
interface Profile {
name: string;
age: number;
@@ -1169,6 +1180,10 @@ function HomeScreen({
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => onNavigate("characters")}
className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center">
<Sparkles size={17}/>
</button>
<button onClick={() => onNavigate("leaderboard")}
className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center">
<Trophy size={17}/>
@@ -2642,6 +2657,106 @@ function LeaderboardScreen({
// ─── Characters Screen (کاتالوگِ گیمیفیکیشنِ بک‌اند) ─────────────────────────
// کاراکترها رو خودِ بک‌اند مدیریت می‌کنه (مثلِ subscription.Plan)، پس تعدادشون
// می‌تونه هر چقدر باشه. اگه slug با سیگما/دلتا/پی جور دربیاد، طرحِ سه‌یارِ
// خودمون (انیمیشن‌دار) نشون داده می‌شه؛ وگرنه یه کارتِ عمومی.
function CharactersScreen({
characters,
loading,
loadErr,
selecting,
onSelect,
onBack,
}: {
characters: ApiCharacter[] | null;
loading: boolean;
loadErr: boolean;
selecting: string | null;
onSelect: (characterId: string) => void;
onBack: () => void;
}) {
const toFa = (n: number) => String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]);
return (
<div className="flex flex-col h-full bg-[#f0f4ff]" dir="rtl">
<div className="flex-shrink-0 bg-[#9B7BFF] px-5 pt-12 pb-6 text-white rounded-b-[32px] relative overflow-hidden">
<div className="pointer-events-none absolute -left-10 -top-10 w-40 h-40 rounded-full bg-white/10"/>
<div className="relative z-10 flex items-center gap-3">
<button onClick={onBack} className="w-8 h-8 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
<ChevronRight size={17} strokeWidth={2.5}/>
</button>
<div className="flex-1">
<h1 className="font-black text-lg">همراهت رو انتخاب کن</h1>
<p className="text-white/70 text-xs mt-0.5">با کسبِ امتیاز، کاراکترهای بیشتری باز می‌شن</p>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-5">
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="sigma" mood="idle" size={64}/>
<p className="text-sm text-[#9e9ec0]">در حال آوردنِ کاراکترها...</p>
</div>
) : loadErr ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="pi" mood="sad" size={64}/>
<p className="text-sm text-[#9e9ec0]">لیست الان نیومد، بعداً دوباره امتحان کن.</p>
</div>
) : !characters || characters.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<SeyarMascot char="pi" mood="idle" size={64}/>
<p className="text-sm text-[#9e9ec0]">فعلاً کاراکتری تعریف نشده</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{characters.map((c) => {
const seyar = seyarSlugMatch(c.slug);
const isBusy = selecting === c.characterId;
return (
<button key={c.characterId}
disabled={!c.unlocked || isBusy}
onClick={() => onSelect(c.characterId)}
className={`relative rounded-3xl p-4 flex flex-col items-center gap-2 border-2 transition-all ${
c.selected ? "bg-[#F5F1FF] border-[#9B7BFF]" :
c.unlocked ? "bg-white border-[#ede9ff] active:scale-95" :
"bg-[#f4f3fa] border-[#ede9ff] opacity-60"
}`}>
{c.selected && (
<span className="absolute top-2 left-2 w-5 h-5 rounded-full bg-[#9B7BFF] flex items-center justify-center">
<Check size={11} className="text-white" strokeWidth={3}/>
</span>
)}
<div className="relative">
{seyar ? (
<SeyarMascot char={seyar} mood={c.unlocked ? "happy" : "idle"} size={80}/>
) : (
<div className="w-20 h-20 rounded-full bg-[#EEF1FF] flex items-center justify-center">
<Sparkles size={28} className="text-[#9B7BFF]"/>
</div>
)}
{!c.unlocked && (
<div className="absolute inset-0 flex items-center justify-center bg-white/40 rounded-full backdrop-blur-[1px]">
<Lock size={22} className="text-[#5a5580]"/>
</div>
)}
</div>
<p className="font-bold text-[#1a1a2e] text-sm">{c.name}</p>
<p className="text-[10px] text-[#9e9ec0]">
{c.unlocked ? (c.selected ? "انتخاب‌شده" : "بازشده") : `با ${toFa(c.unlockXp)} امتیاز باز می‌شه`}
</p>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
// ─── Subscription ─────────────────────────────────────────────────────────────
const PLAN_STYLES = [
@@ -2796,12 +2911,34 @@ export default function App() {
const refreshLeaderboard = (period: LeaderboardPeriod) => {
setLbLoading(true);
setLbErr(false);
api.leaderboard(period as any)
api.leaderboard(period)
.then((r) => setLbEntries(r.entries as unknown as LeaderboardEntry[]))
.catch(() => setLbErr(true))
.finally(() => setLbLoading(false));
};
const [characters, setCharacters] = useState<ApiCharacter[] | null>(null);
const [charLoading, setCharLoading] = useState(false);
const [charErr, setCharErr] = useState(false);
const [charSelecting, setCharSelecting] = useState<string | null>(null);
const refreshCharacters = () => {
setCharLoading(true);
setCharErr(false);
api.characters.list()
.then(setCharacters)
.catch(() => setCharErr(true))
.finally(() => setCharLoading(false));
};
const selectCharacter = (characterId: string) => {
setCharSelecting(characterId);
api.characters.select(characterId)
.then(() => setCharacters((list) => list?.map((c) => ({ ...c, selected: c.characterId === characterId })) ?? list))
.catch(() => {})
.finally(() => setCharSelecting(null));
};
useEffect(() => {
if (flow === "main") { refreshHome(); refreshNotifications(); }
}, [flow]);
@@ -2810,6 +2947,10 @@ export default function App() {
if (flow === "main" && screen === "leaderboard" && !lbEntries) refreshLeaderboard(lbPeriod);
}, [flow, screen]);
useEffect(() => {
if (flow === "main" && screen === "characters" && !characters) refreshCharacters();
}, [flow, screen]);
// ─── تاریخچه‌ی صفحات، برای دکمه‌ی «بازگشت» ────────────────────────────────
// navigate: رفتن به جلو (صفحه‌ی فعلی رو تو تاریخچه نگه می‌داره)
// goBack: برگشت به صفحه‌ی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود)
@@ -2915,6 +3056,7 @@ export default function App() {
{flow === "main" && screen === "home" && <HomeScreen profile={profile!} home={home} summary={summary} unreadCount={unreadCount} onNavigate={navigate} onContinue={(id) => { setUnitId(id); navigate("skillmap"); }}/>}
{flow === "main" && screen === "notifications" && <NotificationsScreen items={notifications} loading={notifLoading} loadErr={notifErr} onBack={goBack} onMarkRead={markNotifRead} onMarkAllRead={markAllNotifRead}/>}
{flow === "main" && screen === "leaderboard" && <LeaderboardScreen entries={lbEntries} period={lbPeriod} loading={lbLoading} loadErr={lbErr} myUserId={auth?.userId} onChangePeriod={(p) => { setLbPeriod(p); refreshLeaderboard(p); }} onBack={goBack}/>}
{flow === "main" && screen === "characters" && <CharactersScreen characters={characters} loading={charLoading} loadErr={charErr} selecting={charSelecting} onSelect={selectCharacter} onBack={goBack}/>}
{flow === "main" && screen === "topics" && <TopicSelectScreen home={home} onSelect={(id) => { setUnitId(id); navigate("skillmap"); }}/>}
{flow === "main" && screen === "lessons" && <LessonsHubScreen home={home} onOpenUnit={(id) => { setUnitId(id); navigate("skillmap"); }} onNavigate={navigate}/>}
{flow === "main" && screen === "skillmap" && <SkillMapScreen unitId={unitId} home={home} onOpenLesson={(id) => { setLessonId(id); navigate("lesson"); }} onBack={goBack}/>}
@@ -2926,4 +3068,4 @@ export default function App() {
</div>
</div>
);
}
}