Compare commits
2 Commits
api-branch
...
d223336638
| Author | SHA1 | Date | |
|---|---|---|---|
| d223336638 | |||
| a341ffd314 |
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# این فایل فقط نمونه است. اگر بکاندِ محلی دارید، آن را به .env کپی کنید:
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# اگر هیچ کاری نکنید هم اپ کار میکند و به بکاندِ مشترک وصل میشود:
|
||||||
|
# https://api.math.tadrisino2.ir/api
|
||||||
|
#
|
||||||
|
# فقط وقتی بکاند را روی ماشینِ خودتان بالا آوردهاید این را باز کنید:
|
||||||
|
# VITE_API_BASE_URL=http://127.0.0.1:8000/api
|
||||||
|
|
||||||
|
# هیچ کلید یا رمزی اینجا نگذارید: هر چیزی که با VITE_ شروع شود داخلِ باندلِ
|
||||||
|
# نهایی میرود و کاربر میتواند ببیندش.
|
||||||
@@ -117,16 +117,6 @@ export interface Hearts {
|
|||||||
isUnlimited: boolean;
|
isUnlimited: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Character {
|
|
||||||
characterId: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
unlockXp: number;
|
|
||||||
unlocked: boolean;
|
|
||||||
selected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AskResponse {
|
export interface AskResponse {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -309,16 +299,9 @@ export function createClient(options: ClientOptions = {}) {
|
|||||||
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
|
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
|
||||||
'GET', '/user/streak/calendar',
|
'GET', '/user/streak/calendar',
|
||||||
),
|
),
|
||||||
leaderboard: (period: 'weekly' | 'all_time' = 'weekly') =>
|
leaderboard: (period: 'weekly' | 'monthly' = 'weekly') =>
|
||||||
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
|
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: {
|
lesson: {
|
||||||
intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`),
|
intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`),
|
||||||
|
|||||||
150
src/app/App.tsx
150
src/app/App.tsx
@@ -12,12 +12,11 @@ import { createClient, ApiError } from "../api";
|
|||||||
import type {
|
import type {
|
||||||
Grade as ApiGrade, HomeResponse, HomeUnit, HomeNode, NodeStatus,
|
Grade as ApiGrade, HomeResponse, HomeUnit, HomeNode, NodeStatus,
|
||||||
LessonIntro, Question as ApiQuestion, AnswerResult, Plan as ApiPlan,
|
LessonIntro, Question as ApiQuestion, AnswerResult, Plan as ApiPlan,
|
||||||
Character as ApiCharacter,
|
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
|
||||||
// ─── کلاینتِ API — تنها جایی که با بکاند حرف میزنیم ─────────────────────────
|
// ─── کلاینتِ API — تنها جایی که با بکاند حرف میزنیم ─────────────────────────
|
||||||
const api = createClient({
|
const api = createClient({
|
||||||
baseUrl: (import.meta as { env?: Record<string, string> }).env?.VITE_API_BASE_URL ?? "https://api.math.tadrisino2.ir/api",
|
baseUrl: (import.meta as { env?: Record<string, string> }).env?.VITE_API_URL ?? "/api",
|
||||||
});
|
});
|
||||||
|
|
||||||
function getOrCreateDeviceId(): string {
|
function getOrCreateDeviceId(): string {
|
||||||
@@ -283,16 +282,6 @@ const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string
|
|||||||
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
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 }) {
|
function SeyarMascot({ char, mood = "idle", size = 100, bob = false }: { char: SeyarChar; mood?: SeyarMood; size?: number; bob?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -311,7 +300,7 @@ function SeyarMascot({ char, mood = "idle", size = 100, bob = false }: { char: S
|
|||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type AppFlow = "splash" | "onboarding" | "login" | "profile" | "main";
|
type AppFlow = "splash" | "onboarding" | "login" | "profile" | "main";
|
||||||
type MainScreen = "home" | "topics" | "skillmap" | "lessons" | "lesson" | "question" | "askai" | "subscription" | "notifications" | "leaderboard" | "characters";
|
type MainScreen = "home" | "topics" | "skillmap" | "lessons" | "lesson" | "question" | "askai" | "subscription" | "notifications" | "leaderboard";
|
||||||
interface Profile {
|
interface Profile {
|
||||||
name: string;
|
name: string;
|
||||||
age: number;
|
age: number;
|
||||||
@@ -1180,10 +1169,6 @@ function HomeScreen({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<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")}
|
<button onClick={() => onNavigate("leaderboard")}
|
||||||
className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center">
|
className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center">
|
||||||
<Trophy size={17}/>
|
<Trophy size={17}/>
|
||||||
@@ -2657,106 +2642,6 @@ 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 ─────────────────────────────────────────────────────────────
|
// ─── Subscription ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PLAN_STYLES = [
|
const PLAN_STYLES = [
|
||||||
@@ -2911,34 +2796,12 @@ export default function App() {
|
|||||||
const refreshLeaderboard = (period: LeaderboardPeriod) => {
|
const refreshLeaderboard = (period: LeaderboardPeriod) => {
|
||||||
setLbLoading(true);
|
setLbLoading(true);
|
||||||
setLbErr(false);
|
setLbErr(false);
|
||||||
api.leaderboard(period)
|
api.leaderboard(period as any)
|
||||||
.then((r) => setLbEntries(r.entries as unknown as LeaderboardEntry[]))
|
.then((r) => setLbEntries(r.entries as unknown as LeaderboardEntry[]))
|
||||||
.catch(() => setLbErr(true))
|
.catch(() => setLbErr(true))
|
||||||
.finally(() => setLbLoading(false));
|
.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(() => {
|
useEffect(() => {
|
||||||
if (flow === "main") { refreshHome(); refreshNotifications(); }
|
if (flow === "main") { refreshHome(); refreshNotifications(); }
|
||||||
}, [flow]);
|
}, [flow]);
|
||||||
@@ -2947,10 +2810,6 @@ export default function App() {
|
|||||||
if (flow === "main" && screen === "leaderboard" && !lbEntries) refreshLeaderboard(lbPeriod);
|
if (flow === "main" && screen === "leaderboard" && !lbEntries) refreshLeaderboard(lbPeriod);
|
||||||
}, [flow, screen]);
|
}, [flow, screen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (flow === "main" && screen === "characters" && !characters) refreshCharacters();
|
|
||||||
}, [flow, screen]);
|
|
||||||
|
|
||||||
// ─── تاریخچهی صفحات، برای دکمهی «بازگشت» ────────────────────────────────
|
// ─── تاریخچهی صفحات، برای دکمهی «بازگشت» ────────────────────────────────
|
||||||
// navigate: رفتن به جلو (صفحهی فعلی رو تو تاریخچه نگه میداره)
|
// navigate: رفتن به جلو (صفحهی فعلی رو تو تاریخچه نگه میداره)
|
||||||
// goBack: برگشت به صفحهی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود)
|
// goBack: برگشت به صفحهی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود)
|
||||||
@@ -3056,7 +2915,6 @@ 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 === "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 === "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 === "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 === "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 === "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}/>}
|
{flow === "main" && screen === "skillmap" && <SkillMapScreen unitId={unitId} home={home} onOpenLesson={(id) => { setLessonId(id); navigate("lesson"); }} onBack={goBack}/>}
|
||||||
@@ -3068,4 +2926,4 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user