Compare commits

..

2 Commits

Author SHA1 Message Date
b411b636f4 Merge pull request 'fixed' (#6) from api-branch into main
Reviewed-on: http://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend/pulls/6
2026-09-24 12:33:08 +03:30
267cf5fca0 fixed 2026-09-24 12:30:58 +03:30
3 changed files with 164 additions and 16 deletions

View File

@@ -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_ شروع شود داخلِ باندلِ
# نهایی می‌رود و کاربر می‌تواند ببیندش.

View File

@@ -117,6 +117,16 @@ 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;
@@ -299,9 +309,16 @@ 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' | 'monthly' = 'weekly') => leaderboard: (period: 'weekly' | 'all_time' = '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`),

View File

@@ -12,11 +12,12 @@ 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_URL ?? "/api", baseUrl: (import.meta as { env?: Record<string, string> }).env?.VITE_API_BASE_URL ?? "https://api.math.tadrisino2.ir/api",
}); });
function getOrCreateDeviceId(): string { function getOrCreateDeviceId(): string {
@@ -282,6 +283,16 @@ 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
@@ -300,7 +311,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"; type MainScreen = "home" | "topics" | "skillmap" | "lessons" | "lesson" | "question" | "askai" | "subscription" | "notifications" | "leaderboard" | "characters";
interface Profile { interface Profile {
name: string; name: string;
age: number; age: number;
@@ -1169,6 +1180,10 @@ 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}/>
@@ -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 ───────────────────────────────────────────────────────────── // ─── Subscription ─────────────────────────────────────────────────────────────
const PLAN_STYLES = [ const PLAN_STYLES = [
@@ -2796,12 +2911,34 @@ export default function App() {
const refreshLeaderboard = (period: LeaderboardPeriod) => { const refreshLeaderboard = (period: LeaderboardPeriod) => {
setLbLoading(true); setLbLoading(true);
setLbErr(false); setLbErr(false);
api.leaderboard(period as any) api.leaderboard(period)
.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]);
@@ -2810,6 +2947,10 @@ 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: برگشت به صفحه‌ی قبلی (یا خانه، اگه چیزی تو تاریخچه نبود)
@@ -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 === "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}/>}