fixed
This commit is contained in:
11
.env.example
11
.env.example
@@ -1,11 +0,0 @@
|
||||
# این فایل فقط نمونه است. اگر بکاندِ محلی دارید، آن را به .env کپی کنید:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# اگر هیچ کاری نکنید هم اپ کار میکند و به بکاندِ مشترک وصل میشود:
|
||||
# https://api.math.tadrisino2.ir/api
|
||||
#
|
||||
# فقط وقتی بکاند را روی ماشینِ خودتان بالا آوردهاید این را باز کنید:
|
||||
# VITE_API_BASE_URL=http://127.0.0.1:8000/api
|
||||
|
||||
# هیچ کلید یا رمزی اینجا نگذارید: هر چیزی که با VITE_ شروع شود داخلِ باندلِ
|
||||
# نهایی میرود و کاربر میتواند ببیندش.
|
||||
@@ -117,6 +117,16 @@ export interface Hearts {
|
||||
isUnlimited: boolean;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
characterId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
unlockXp: number;
|
||||
unlocked: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface AskResponse {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
@@ -299,9 +309,16 @@ export function createClient(options: ClientOptions = {}) {
|
||||
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
|
||||
'GET', '/user/streak/calendar',
|
||||
),
|
||||
leaderboard: (period: 'weekly' | 'monthly' = 'weekly') =>
|
||||
leaderboard: (period: 'weekly' | 'all_time' = 'weekly') =>
|
||||
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
|
||||
|
||||
/** کاتالوگِ کاراکترهای گیمیفیکیشن — مثلِ ماسکاتهای Duolingo، بازشدن بر اساسِ XP. */
|
||||
characters: {
|
||||
list: () => raw<{ characters: Character[] }>('GET', '/characters').then((r) => r.characters),
|
||||
select: (characterId: string) =>
|
||||
raw<{ characterId: string; selected: boolean }>('POST', '/characters/select', { characterId }),
|
||||
},
|
||||
|
||||
/* ── صفحهی درس و سوال ─────────────────────────────────────────────── */
|
||||
lesson: {
|
||||
intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`),
|
||||
|
||||
148
src/app/App.tsx
148
src/app/App.tsx
@@ -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}/>}
|
||||
|
||||
Reference in New Issue
Block a user