rebuild the app on a typed, modular structure wired to the real API

The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused
packages, 45 unused UI components, no type-check). The live site could not
reach the backend, Persian-keyboard digits were erased from the phone field,
guests were signed out after an hour, "mark read" hit a method the server
does not accept (405), and iOS Safari blocked the payment window.

What changed:
- src/ split into api/ (one typed client: timeouts, GET retry, single-flight
  refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/.
- The API address is configuration only: public/config.js at runtime, then
  VITE_API_BASE_URL at build time, then same-origin /api. No domain in code.
- Responsive shell instead of a fixed 390x844 phone frame; self-hosted
  Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build`
  type-checks first.
- Everything on the handoff list: tutor answers keep their line breaks, have
  thumbs up/down, follow-ups, "from the book" badge, retry, history that
  survives tab switches and reloads, "why was I wrong" from a wrong answer
  with questionId; question reports; hearts with maxHearts/unlimited and a
  no-hearts screen with the refill time; result screen and a visible error
  when lesson completion fails; subscription status, badge, discount code
  before the gateway and the payment-return message; guest refresh token;
  6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH
  read-all; leaderboard refetched on open with "you" highlighted; selected
  companion shown across the app; logout that revokes the refresh token.
- New screens for endpoints that had none: profile, streak calendar,
  support tickets and bug reports, parents, invite-a-friend (with ?ref=
  tracking), guest-to-phone upgrade.
- Browser/phone back button walks the screen stack.

Checked with a headless-Chrome walkthrough against a local backend at
390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map,
island sheet, every tab and sub-screen, one real tutor question, reload,
logout): no console errors, no failed API calls. Real SMS login and a real
Zarinpal payment were not exercised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 23:35:48 +03:30
parent b411b636f4
commit 348bfecea6
120 changed files with 5840 additions and 12783 deletions

291
src/screens/AskScreen.tsx Normal file
View File

@@ -0,0 +1,291 @@
import { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { useToast } from '@/components/Toast';
import { Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
const STARTERS = ['این فصل درباره‌ی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
/** «از پی بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد. */
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
const { profile } = useAppData();
const chat = useAskState();
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [quota, setQuota] = useState<number | null>(null);
const listEnd = useRef<HTMLDivElement>(null);
const autoSent = useRef(false);
// درسِ تازه یعنی گفتگوی تازه؛ وگرنه اگر گفتگوی قبلی هست، تاریخچه‌اش را بیاور.
useEffect(() => {
if (lessonId !== undefined && lessonId !== chat.lessonId) {
askStore.reset(lessonId);
return;
}
if (chat.historyLoaded) return;
if (!chat.conversationId) {
askStore.markHistoryLoaded();
return;
}
api.ai
.history(chat.conversationId)
.then((r) =>
askStore.setHistory(
r.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })),
),
)
.catch(() => askStore.reset(null));
// فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
}, []);
useEffect(() => {
if (prompt && !autoSent.current) {
autoSent.current = true;
void send(prompt, questionId);
}
}, [prompt, questionId]);
// بعد از رندر اسکرول کن، تا پیشنهادهای ادامه که آخر از همه می‌آیند هم دیده شوند.
useEffect(() => {
const frame = requestAnimationFrame(() => listEnd.current?.scrollIntoView({ block: 'end' }));
return () => cancelAnimationFrame(frame);
}, [chat.messages]);
async function send(text: string, forQuestionId?: string) {
const question = text.trim();
if (!question || busy) return;
setBusy(true);
setInput('');
askStore.add({ role: 'user', text: question });
const replyId = askStore.add({ role: 'assistant', text: '' });
let answer = '';
try {
const done = await api.ai.ask(
{
userQuestion: question,
conversationId: askStore.get().conversationId ?? undefined,
lessonId: askStore.get().lessonId ?? undefined,
questionId: forQuestionId,
},
(delta) => {
answer += delta;
askStore.update(replyId, { text: answer });
},
);
askStore.update(replyId, {
text: answer,
messageId: done.messageId,
usedChunks: done.usedChunks,
followUps: done.suggestedFollowUps,
});
askStore.setConversation(done.conversationId);
setQuota(done.remainingQuota);
} catch (err) {
askStore.update(replyId, {
text: friendlyError(err, 'جوابی نرسید'),
failedQuestion: question,
});
} finally {
setBusy(false);
}
}
function retry(message: ChatMessage) {
if (!message.failedQuestion) return;
const all = askStore.get().messages;
const index = all.findIndex((m) => m.localId === message.localId);
const question = all[index - 1];
askStore.remove(message.localId);
if (question?.role === 'user') askStore.remove(question.localId);
void send(message.failedQuestion);
}
const empty = chat.messages.length === 0;
return (
<Screen>
<ScreenHeader
title="از پی بپرس"
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب می‌دم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
color="#9B7BFF"
back={lessonId !== undefined || prompt !== undefined}
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
>
{!empty && (
<button
type="button"
onClick={() => askStore.reset(null)}
disabled={busy}
className="mt-3 flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
>
<MessageSquarePlus size={14} /> گفتگوی تازه
</button>
)}
</ScreenHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant">
سلام {profile.name || 'دوستِ من'}! من پی‌ام 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم.
</Bubble>
{empty && chat.historyLoaded && (
<div className="flex flex-wrap gap-2 pr-10">
{STARTERS.map((s) => (
<button key={s} type="button" onClick={() => void send(s)} className="rounded-full border border-[#dcd2ff] bg-white px-3 py-1.5 text-xs font-bold text-[#7857E0]">
{s}
</button>
))}
</div>
)}
{chat.messages.map((m, i) => (
<Message key={m.localId} message={m} last={i === chat.messages.length - 1} busy={busy} onRetry={retry} onFollowUp={(q) => void send(q)} />
))}
<div ref={listEnd} />
</div>
<form
className="flex-shrink-0 border-t border-line bg-white px-4 py-3"
onSubmit={(e) => {
e.preventDefault();
void send(input);
}}
>
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value.slice(0, 1000))}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void send(input);
}
}}
rows={1}
placeholder="سوالت رو بنویس…"
className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint"
/>
<button
type="submit"
aria-label="ارسال"
disabled={!input.trim() || busy}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-violet text-white transition-opacity disabled:opacity-40"
>
<Send size={16} className="-scale-x-100" />
</button>
</div>
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه می‌تونی بپرسی</p>}
</form>
</Screen>
);
}
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
const mine = role === 'user';
return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && (
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="happy" size={32} />
</span>
)}
<div
className={`max-w-[82%] whitespace-pre-line break-words rounded-2xl px-4 py-3 text-sm leading-[1.95] ${
mine ? 'rounded-tl-sm bg-coral text-white' : 'rounded-tr-sm border border-line bg-white text-ink shadow-sm'
}`}
>
{children}
</div>
</div>
);
}
function Message({
message, last, busy, onRetry, onFollowUp,
}: {
message: ChatMessage;
last: boolean;
busy: boolean;
onRetry: (m: ChatMessage) => void;
onFollowUp: (q: string) => void;
}) {
const toast = useToast();
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>;
if (!message.text) {
return (
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="idle" size={32} />
</span>
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted">
پی داره فکر می‌کنه
{[0, 1, 2].map((i) => (
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
))}
</div>
</div>
);
}
async function rate(helpful: boolean) {
if (!message.messageId || message.feedback) return;
askStore.update(message.localId, { feedback: helpful ? 'up' : 'down' });
try {
await api.ai.feedback(message.messageId, helpful);
toast(helpful ? 'ممنون! 🙂' : 'ممنون که گفتی؛ بهترش می‌کنیم.', 'success');
} catch {
askStore.update(message.localId, { feedback: undefined });
toast('نظرت ثبت نشد', 'error');
}
}
return (
<div className="flex flex-col gap-1.5">
<Bubble role="assistant">{message.text}</Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? (
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
<RotateCw size={12} /> دوباره امتحان کن
</button>
) : (
<>
{message.usedChunks && message.usedChunks.length > 0 && (
<span className="flex items-center gap-1 text-[10px] font-bold text-mint" title={message.usedChunks.map((c) => c.title).join('، ')}>
<BookOpen size={11} /> از روی کتاب
</span>
)}
{message.messageId && (
<span className="mr-auto flex gap-1">
<button type="button" aria-label="مفید بود" onClick={() => void rate(true)} disabled={!!message.feedback} className={`rounded-full p-1.5 ${message.feedback === 'up' ? 'bg-green-100 text-green-600' : 'text-faint'}`}>
<ThumbsUp size={13} />
</button>
<button type="button" aria-label="مفید نبود" onClick={() => void rate(false)} disabled={!!message.feedback} className={`rounded-full p-1.5 ${message.feedback === 'down' ? 'bg-red-100 text-red-500' : 'text-faint'}`}>
<ThumbsDown size={13} />
</button>
</span>
)}
</>
)}
</div>
{last && !busy && message.followUps && message.followUps.length > 0 && (
<div className="flex flex-wrap gap-2 pr-10">
{message.followUps.map((f) => (
<button key={f} type="button" onClick={() => onFollowUp(f)} className="rounded-full border border-[#dcd2ff] bg-white px-3 py-1.5 text-xs font-bold text-[#7857E0]">
{f}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Check, Lock, Sparkles } from 'lucide-react';
import { api, type Character } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar';
import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
/**
* همراه‌ها (کاتالوگِ کاراکترهای بک‌اند). همراهِ انتخابی در خانه، درس و تمرین
* نشان داده می‌شود. باز شدن فقط با امتیاز است.
*/
export function CharactersScreen() {
const { summary, setCompanion } = useAppData();
const toast = useToast();
const list = useAsync(() => api.characters.list(), []);
const [busy, setBusy] = useState<string | null>(null);
async function select(c: Character) {
if (!c.unlocked || c.selected || busy) return;
setBusy(c.characterId);
try {
await api.characters.select(c.characterId);
list.setData((prev) => prev?.map((x) => ({ ...x, selected: x.characterId === c.characterId })));
setCompanion(c);
toast(`${c.name} حالا همراهته!`, 'success');
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
setBusy(null);
}
}
return (
<Screen>
<ScreenHeader
title="همراهت رو انتخاب کن"
subtitle={`امتیازِ تو: ${toFa(summary?.xp ?? 0)} · با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن`}
color="#9B7BFF"
back
/>
<div className="flex-1 overflow-y-auto px-4 py-5">
{list.loading ? (
<LoadingState />
) : list.error ? (
<ErrorState error={list.error} onRetry={list.reload} />
) : !list.data?.length ? (
<EmptyState text="فعلاً همراهی تعریف نشده" />
) : (
<div className="grid grid-cols-2 gap-3">
{list.data.map((c) => {
const seyar = seyarFromSlug(c.slug);
return (
<button
key={c.characterId}
type="button"
disabled={!c.unlocked || busy !== null}
onClick={() => void select(c)}
aria-pressed={c.selected}
className={`relative flex flex-col items-center gap-2 rounded-3xl border-2 p-4 transition-transform ${
c.selected ? 'border-violet bg-[#F5F1FF]' : c.unlocked ? 'border-line bg-white active:scale-95' : 'border-line bg-[#f4f3fa]'
}`}
>
{c.selected && (
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-violet">
<Check size={11} className="text-white" strokeWidth={3} />
</span>
)}
<span className={`relative ${c.unlocked ? '' : 'opacity-50 grayscale'}`}>
{seyar ? (
<SeyarMascot char={seyar} mood={c.unlocked ? 'happy' : 'idle'} size={84} />
) : (
<span className="flex h-20 w-20 items-center justify-center rounded-full bg-[#EEF1FF]">
<Sparkles size={28} className="text-violet" />
</span>
)}
</span>
{!c.unlocked && (
<span className="absolute top-12 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow">
<Lock size={16} className="text-[#5a5580]" />
</span>
)}
<span className="text-sm font-bold text-ink">{c.name}</span>
<span className="text-center text-[10px] leading-5 text-muted">
{!c.unlocked
? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه`
: c.selected
? 'همراهِ فعلی'
: seyar
? SEYAR_INFO[seyar].role
: 'بازشده'}
</span>
</button>
);
})}
</div>
)}
</div>
</Screen>
);
}

View File

@@ -0,0 +1,168 @@
import { useState } from 'react';
import { BookOpen, Flame, Link2, Star, Trophy, UserPlus } from 'lucide-react';
import { api, ApiError, type FamilyLink } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { InlineError, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
/**
* بخشِ والدین. والد با شماره‌ی فرزند دعوت می‌سازد و یک کد می‌گیرد؛ فرزند
* (با همان شماره وارد شده) کد را اینجا وارد می‌کند. بعد از اتصال، والد
* گزارشِ هفتگی را می‌بیند.
*/
export function FamilyScreen() {
const link = useAsync(
() => api.family.link().catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}),
[],
);
return (
<Screen>
<ScreenHeader title="بخشِ والدین" subtitle="پیشرفتِ فرزندت رو هر هفته ببین" color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{link.loading ? (
<LoadingState />
) : (
<>
{link.data && <LinkStatus link={link.data} />}
{link.data?.status === 'accepted' && <WeeklyReport />}
<InviteChild onDone={link.reload} />
<AcceptInvite onDone={link.reload} />
</>
)}
</ScreenBody>
</Screen>
);
}
function LinkStatus({ link }: { link: FamilyLink }) {
const accepted = link.status === 'accepted';
return (
<div className={`flex items-center gap-3 rounded-3xl p-4 ${accepted ? 'bg-[#EAF8F0]' : 'bg-[#FFF8E1]'}`}>
<Link2 size={20} className={accepted ? 'text-mint' : 'text-gold'} />
<p className="text-sm leading-7 text-ink">
{accepted ? <>به <b>{link.childName || 'فرزندت'}</b> وصلی.</> : 'دعوت فرستاده شده و منتظرِ تأییدِ فرزندته.'}
</p>
</div>
);
}
function WeeklyReport() {
const report = useAsync(() => api.family.weeklyReport(), []);
if (report.loading) return <div className="h-32 animate-pulse rounded-3xl bg-soft" />;
if (!report.data) return null;
const r = report.data;
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-3 text-sm font-black text-ink">گزارشِ این هفته <span className="text-xs font-medium text-muted">(از {formatJalaliDate(r.weekStart)})</span></h2>
<div className="grid grid-cols-2 gap-2">
{[
{ icon: Star, label: 'امتیاز', value: r.xpEarned },
{ icon: BookOpen, label: 'درسِ تمام‌شده', value: r.lessonsCompleted },
{ icon: Trophy, label: 'جزیره‌ی تمام‌شده', value: r.islandsCompleted },
{ icon: Flame, label: 'زنجیره (روز)', value: r.currentStreak },
].map(({ icon: Icon, label, value }) => (
<div key={label} className="flex items-center gap-2 rounded-2xl bg-soft px-3 py-2.5">
<Icon size={16} className="text-orange" />
<span className="flex-1 text-xs text-body">{label}</span>
<span className="text-sm font-black text-ink">{toFa(value)}</span>
</div>
))}
</div>
</section>
);
}
function InviteChild({ onDone }: { onDone: () => void }) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function invite() {
setBusy(true);
setError('');
try {
const r = await api.family.invite(phone);
setCode(r.inviteCode);
onDone();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-1 flex items-center gap-2 text-sm font-black text-ink"><UserPlus size={16} className="text-orange" /> من والدم</h2>
<p className="mb-3 text-xs leading-6 text-muted">شماره‌ای که فرزندت باهاش وارد تدریسینو شده رو بنویس.</p>
{code ? (
<div className="rounded-2xl bg-soft p-4 text-center">
<p className="text-xs text-muted">این کد رو به فرزندت بده تا تو بخشِ والدینِ حسابِ خودش وارد کنه:</p>
<p dir="ltr" className="mt-2 text-2xl font-black tracking-widest text-ink">{code}</p>
</div>
) : (
<>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-left outline-none focus:border-orange"
/>
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!/^09\d{9}$/.test(phone)} onClick={() => void invite()}>
ساختِ کدِ دعوت
</PrimaryButton>
</>
)}
</section>
);
}
function AcceptInvite({ onDone }: { onDone: () => void }) {
const toast = useToast();
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function accept() {
setBusy(true);
setError('');
try {
await api.family.accept(code.trim());
toast('به والدت وصل شدی', 'success');
setCode('');
onDone();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-1 text-sm font-black text-ink">من دانش‌آموزم</h2>
<p className="mb-3 text-xs leading-6 text-muted">اگه پدر یا مادرت کدِ دعوت بهت داده، اینجا واردش کن.</p>
<input
value={code}
onChange={(e) => setCode(e.target.value.slice(0, 32))}
placeholder="کدِ دعوت"
dir="ltr"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-orange"
/>
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!code.trim()} onClick={() => void accept()}>
وصل شو
</PrimaryButton>
</section>
);
}

180
src/screens/HomeScreen.tsx Normal file
View File

@@ -0,0 +1,180 @@
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react';
import { useAppData } from '@/state/AppData';
import { findContinue, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
export function HomeScreen() {
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
const nav = useNav();
const cont = findContinue(home);
const contLesson = cont ? nextLessonOf(cont.node) : null;
const contColor = cont ? unitColor(cont.unitIndex) : '#ED6956';
const contProgress = cont ? unitProgress(cont.unit) : null;
const openLesson = (quiz = false) => {
if (!cont || contLesson == null) return;
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id });
};
const stats = [
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', fill: true, to: 'streak' as const },
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' as const },
];
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white">
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" />
<div className="relative flex items-center justify-between pt-8">
<div className="min-w-0">
<p className="text-sm text-white/75">سلام {profile.name || 'دوستِ من'}!</p>
<h1 className="mt-0.5 text-xl font-black leading-snug">آماده‌ای ریاضی رو ادامه بدیم؟</h1>
{gradeTitle && (
<span className="mt-1.5 inline-block rounded-full bg-white/15 px-2.5 py-0.5 text-xs text-white/85">{gradeTitle}</span>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<button type="button" aria-label="جدولِ امتیازها" onClick={() => nav.push({ name: 'leaderboard' })} className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
<Trophy size={18} />
</button>
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -left-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
</span>
)}
</button>
</div>
</div>
</header>
<div className="relative z-10 -mt-7 px-4">
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10">
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => (
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5">
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}>
<Icon size={18} style={{ color: fg }} strokeWidth={2.4} fill={fill ? fg : 'none'} />
</span>
<span className="text-lg font-black leading-none text-ink">{toFa(value)}</span>
<span className="mt-1 text-[10px] text-muted">{label}</span>
</button>
))}
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-6 pt-4">
{homeError && !home ? (
<ErrorState error={homeError} onRetry={() => void refreshHome()} />
) : cont && contProgress ? (
<Card className="shadow-md shadow-coral/10">
<div className="mb-3 flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted">ادامه از جایی که موندی</p>
<h3 className="mt-0.5 text-base font-black text-ink">{cont.node.title}</h3>
<p className="mt-0.5 truncate text-xs text-muted">
{cont.unit.title} · {toFa(contProgress.done)} از {toFa(contProgress.total)} جزیره
</p>
</div>
<SeyarMascot char={companion} mood="happy" size={62} className="mr-3" />
</div>
<div className="mb-3">
<ProgressBar percent={contProgress.percent} color={contColor} />
</div>
<button
type="button"
onClick={() => openLesson()}
className="flex w-full items-center justify-center gap-2 rounded-2xl py-3 text-sm font-bold text-white active:scale-[0.98]"
style={{ backgroundColor: contColor }}
>
<Play size={15} fill="white" /> ادامه بده
</button>
</Card>
) : (
<Card className="flex items-center gap-3 p-5">
<SeyarMascot char={companion} mood={home ? 'excited' : 'idle'} size={56} />
<p className="text-sm font-medium text-muted">
{!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همه‌ی جزیره‌های این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'}
</p>
</Card>
)}
<div className="grid grid-cols-2 gap-3">
<Card onClick={() => nav.switchTab('map')} className="p-4">
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-[#EAF2F6]">
<Map size={20} className="text-steel" />
</span>
<div className="text-sm font-bold text-ink">نقشه‌ی فصل‌ها</div>
<div className="mt-0.5 text-[11px] text-muted">ببین کجای مسیری</div>
</Card>
<button
type="button"
onClick={() => nav.switchTab('ask')}
className="rounded-3xl bg-violet p-4 text-right shadow-md shadow-violet/30 active:scale-[0.98]"
>
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-white/15">
<Brain size={20} className="text-white" />
</span>
<div className="text-sm font-bold text-white">از پی بپرس</div>
<div className="mt-0.5 text-[11px] text-white/75">هر جا گیر کردی</div>
</button>
</div>
{home && home.units.length > 0 && (
<section>
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-black text-ink">فصل‌های {gradeTitle || 'این پایه'}</h2>
<span className="text-[11px] text-muted">{toFa(home.units.length)} فصل</span>
</div>
<div className="no-scrollbar -mx-4 flex gap-3 overflow-x-auto px-4 pb-1">
{home.units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i);
return (
<button
key={unit.id}
type="button"
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="w-[140px] flex-shrink-0 rounded-2xl border border-line bg-white p-3.5 text-right shadow-sm active:scale-95"
>
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1f` }}>
<Icon size={19} style={{ color }} />
</span>
<div className="line-clamp-2 min-h-[2.5em] text-[12.5px] font-bold leading-snug text-ink">{unit.title}</div>
<div className="mt-1 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
<div className="mt-2">
<ProgressBar percent={p.percent} color={color} track="#f0eeff" height={6} />
</div>
</button>
);
})}
</div>
</section>
)}
{cont && contLesson != null && (
<button
type="button"
onClick={() => openLesson(true)}
className="flex items-center gap-3 rounded-3xl bg-orange p-4 text-right shadow-md shadow-orange/25 active:scale-[0.98]"
>
<SeyarMascot char="delta" mood="excited" size={64} />
<div className="min-w-0 flex-1">
<div className="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div>
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div>
</div>
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
</button>
)}
</div>
</div>
</Screen>
);
}

View File

@@ -0,0 +1,85 @@
import { Copy, Gift, Share2, Users } from 'lucide-react';
import { api } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { formatToman, toFa } from '@/lib/format';
/** لینکِ دعوت: هر کس با آن بیاید و شماره ثبت کند، معرفیِ تو حساب می‌شود. */
export function inviteLink(code: string): string {
return `${window.location.origin}/?ref=${encodeURIComponent(code)}`;
}
export function InviteScreen() {
const toast = useToast();
const me = useAsync(() => api.affiliate.me(), []);
async function share(code: string) {
const url = inviteLink(code);
const text = 'با تدریسینو ریاضی رو قدم‌به‌قدم یاد بگیر:';
if (navigator.share) {
try {
await navigator.share({ title: 'تدریسینو', text, url });
return;
} catch {
/* کاربر لغو کرد؛ می‌رویم سراغِ کپی */
}
}
try {
await navigator.clipboard.writeText(`${text} ${url}`);
toast('لینک کپی شد', 'success');
} catch {
toast(url);
}
}
return (
<Screen>
<ScreenHeader title="معرفی به دوستان" subtitle="دوستات رو بیار، با هم ریاضی بخونید" color="#5FBF8F" back mascot={{ char: 'delta', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{me.loading ? (
<LoadingState />
) : me.error || !me.data ? (
<ErrorState error={me.error} onRetry={me.reload} />
) : (
<>
<div className="flex flex-col items-center gap-2 rounded-3xl border border-line bg-white p-5 text-center">
<Gift size={28} className="text-mint" />
<span className="text-xs text-muted">کدِ معرفیِ تو</span>
<button
type="button"
onClick={() => void navigator.clipboard?.writeText(me.data!.referralCode).then(() => toast('کد کپی شد', 'success'))}
className="flex items-center gap-2 rounded-2xl bg-soft px-5 py-2.5 text-xl font-black tracking-widest text-ink"
dir="ltr"
>
{me.data.referralCode} <Copy size={16} className="text-muted" />
</button>
</div>
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'معرفی‌شده', value: toFa(me.data.referredCount) },
{ label: 'خرید کرده', value: toFa(me.data.paidCount) },
{ label: 'پورسانت', value: formatToman(me.data.totalCommission) },
].map((s) => (
<div key={s.label} className="flex flex-col items-center rounded-2xl border border-line bg-white px-1 py-3 text-center">
<span className="text-sm font-black text-ink">{s.value}</span>
<span className="mt-0.5 text-[10px] text-muted">{s.label}</span>
</div>
))}
</div>
<p className="flex items-start gap-2 rounded-2xl bg-[#EAF8F0] p-3.5 text-xs leading-6 text-body">
<Users size={16} className="mt-0.5 flex-shrink-0 text-mint" />
دوستت باید با لینکِ تو وارد بشه و شماره‌ش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانتِ توئه.
</p>
<PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}>
<Share2 size={17} /> فرستادنِ لینکِ دعوت
</PrimaryButton>
</>
)}
</ScreenBody>
</Screen>
);
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { api, type LeaderboardPeriod } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
const MEDAL: Record<number, { bg: string; fg: string; h: number }> = {
1: { bg: '#FFD23F', fg: '#8a6d00', h: 92 },
2: { bg: '#D9DEE8', fg: '#5a6270', h: 72 },
3: { bg: '#E8B27A', fg: '#7a4a1a', h: 56 },
};
/** جدولِ امتیازها؛ هر بار که باز شود تازه گرفته می‌شود. */
export function LeaderboardScreen() {
const { profile } = useAppData();
const [period, setPeriod] = useState<LeaderboardPeriod>('weekly');
const board = useAsync(() => api.leaderboard(period), [period]);
const entries = board.data ?? [];
const podium = [entries[1], entries[0], entries[2]].filter(Boolean);
const isMe = (userId: string) => userId === profile.userId;
return (
<Screen>
<ScreenHeader title="جدولِ امتیازها" color="#FF7A3D" back mascot={{ char: 'delta', mood: 'excited', size: 56 }}>
<div className="mt-4 flex gap-1 rounded-2xl bg-white/15 p-1" role="tablist">
{([['weekly', 'این هفته'], ['all_time', 'همه‌ی زمان‌ها']] as const).map(([p, label]) => (
<button
key={p}
type="button"
role="tab"
aria-selected={period === p}
onClick={() => setPeriod(p)}
className={`flex-1 rounded-xl py-2 text-xs font-bold ${period === p ? 'bg-white text-orange' : 'text-white/85'}`}
>
{label}
</button>
))}
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">
{board.loading ? (
<LoadingState char="delta" text="دارم جدول رو میارم…" />
) : board.error ? (
<ErrorState error={board.error} onRetry={board.reload} />
) : entries.length === 0 ? (
<EmptyState text={period === 'weekly' ? 'این هفته هنوز کسی امتیاز نگرفته؛ اولی تو باش!' : 'هنوز کسی تو جدول نیست'} />
) : (
<>
<div className="mb-6 flex items-end justify-center gap-2.5 pt-4">
{podium.map((e) => {
const m = MEDAL[e.rank] ?? MEDAL[3];
return (
<div key={e.userId} className="flex w-24 flex-col items-center gap-1.5">
<span
className={`flex h-14 w-14 items-center justify-center rounded-full border-2 text-lg font-black ${isMe(e.userId) ? 'border-coral' : 'border-white'}`}
style={{ backgroundColor: m.bg, color: m.fg }}
>
{toFa(e.rank)}
</span>
<span className={`w-full truncate text-center text-xs font-bold ${isMe(e.userId) ? 'text-coral' : 'text-ink'}`}>{isMe(e.userId) ? 'تو' : e.name || 'بی‌نام'}</span>
<span className="text-[10px] text-muted">{toFa(e.xpInPeriod)} امتیاز</span>
<span className="mt-1 w-full rounded-t-xl" style={{ height: m.h, backgroundColor: m.bg, opacity: 0.35 }} />
</div>
);
})}
</div>
<ul className="flex flex-col gap-2">
{entries.slice(3).map((e) => (
<li
key={e.userId}
className={`flex items-center gap-3 rounded-2xl p-3 ${isMe(e.userId) ? 'border-2 border-orange/40 bg-[#FFF1E6]' : 'border border-line bg-white'}`}
>
<span className="w-7 flex-shrink-0 text-center text-sm font-black text-muted">{toFa(e.rank)}</span>
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-soft text-xs font-bold text-muted">{e.name?.[0] ?? '؟'}</span>
<span className={`flex-1 truncate text-sm ${isMe(e.userId) ? 'font-bold text-[#B34E1C]' : 'text-ink'}`}>{isMe(e.userId) ? 'تو' : e.name || 'بی‌نام'}</span>
<span className="flex-shrink-0 text-xs font-bold text-muted">{toFa(e.xpInPeriod)} امتیاز</span>
</li>
))}
</ul>
</>
)}
</div>
</Screen>
);
}

View File

@@ -0,0 +1,102 @@
import { Brain, Play, Zap } from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
import { findNode, findNodeOfLesson, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { moodFromServer } from '@/mascots/seyar';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
/** معرفیِ درس، پیش از تمرین. */
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
const { home, companion } = useAppData();
const nav = useNav();
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId]);
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const data = intro.data;
return (
<Screen>
<ScreenHeader
title={data?.title ?? '…'}
color={color}
back
mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }}
subtitle={
ref && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1">
{/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شماره‌ی درس از آن درنمی‌آید. */}
<Zap size={11} fill="white" /> {ref.node.title} · {toFa(ref.node.lessonIds.length)} درس
</span>
)
}
/>
<ScreenBody className="flex flex-col gap-4">
{intro.loading ? (
<LoadingState text="دارم درس رو میارم…" />
) : intro.error || !data ? (
<ErrorState error={intro.error} onRetry={intro.reload} />
) : (
<>
<div className="flex items-start gap-3">
<SeyarMascot char="sigma" mood="idle" size={56} />
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
<p className="whitespace-pre-line text-sm leading-[1.95] text-body">{data.introText}</p>
</div>
</div>
{data.kind === 'video' && data.videoUrl && (
<video src={data.videoUrl} controls playsInline preload="metadata" className="w-full rounded-3xl bg-black" />
)}
{data.example && (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink">{data.example}</p>
</section>
)}
{data.steps.length > 0 && (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-3 text-xs font-black text-muted">قدم‌به‌قدم</h2>
<ol className="flex flex-col gap-2.5">
{data.steps.map((step, i) => (
<li key={i} className="flex gap-3 text-sm leading-7 text-ink">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)}
</span>
<span className="flex-1 whitespace-pre-line">{step}</span>
</li>
))}
</ol>
</section>
)}
<div className="mt-auto flex flex-col gap-3 pt-2">
{data.questionCount === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted">
تمرینِ این درس به‌زودی اضافه می‌شه
</div>
) : (
<PrimaryButton color={color} onClick={() => nav.replace({ name: 'quiz', lessonId, nodeId: ref?.node.id ?? nodeId })}>
<Play size={18} fill="white" /> شروعِ تمرین ({toFa(data.questionCount)} سوال)
</PrimaryButton>
)}
<button
type="button"
onClick={() => nav.push({ name: 'ask', lessonId })}
className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]"
>
<Brain size={16} /> درباره‌ی این درس از پی بپرس
</button>
</div>
</>
)}
</ScreenBody>
</Screen>
);
}

209
src/screens/LoginScreen.tsx Normal file
View File

@@ -0,0 +1,209 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronRight, Phone } from 'lucide-react';
import { api } from '@/api';
import { InlineError, PrimaryButton } from '@/components/ui';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { friendlyError } from '@/lib/errors';
import { digitsOnly, toFa } from '@/lib/format';
import { getDeviceId } from '@/lib/storage';
const OTP_LENGTH = 6;
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
const RESEND_SECONDS = 120;
/**
* ورود با شماره (OTP) یا به‌عنوانِ مهمان.
*
* `upgrade` برای مهمانی است که از صفحه‌ی «من» می‌خواهد شماره ثبت کند؛ آنجا
* گزینه‌ی مهمان نیست و سرور همان کاربر را نگه می‌دارد.
*/
export function LoginScreen({
onDone,
onCancel,
upgrade = false,
}: {
onDone: () => void;
onCancel?: () => void;
upgrade?: boolean;
}) {
const [step, setStep] = useState<'phone' | 'otp'>('phone');
const [phone, setPhone] = useState('');
const [otp, setOtp] = useState('');
const [devOtp, setDevOtp] = useState<string | null>(null);
const [loading, setLoading] = useState<'otp' | 'login' | 'guest' | null>(null);
const [error, setError] = useState('');
const [resendIn, setResendIn] = useState(0);
const otpInput = useRef<HTMLInputElement>(null);
const phoneValid = /^09\d{9}$/.test(phone);
const otpValid = otp.length >= 4;
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
useEffect(() => {
if (step === 'otp') otpInput.current?.focus();
}, [step]);
async function requestOtp() {
if (!phoneValid || loading) return;
setError('');
setLoading('otp');
try {
const data = await api.auth.requestOtp(phone);
setDevOtp(data.devOtp ?? null);
setOtp('');
setStep('otp');
setResendIn(RESEND_SECONDS);
} catch (err) {
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
} finally {
setLoading(null);
}
}
async function verify(code = otp) {
if (code.length < 4 || loading) return;
setError('');
setLoading('login');
try {
await api.auth.login(phone, code);
onDone();
} catch (err) {
setError(friendlyError(err, 'کد درست نبود، دوباره امتحان کن'));
setOtp('');
} finally {
setLoading(null);
}
}
async function continueAsGuest() {
setError('');
setLoading('guest');
try {
await api.auth.guest(getDeviceId());
onDone();
} catch (err) {
// قبلاً اینجا یک «مهمانِ محلی» بدونِ توکن ساخته می‌شد و بعد همه‌ی
// درخواست‌ها بی‌صدا شکست می‌خوردند. حالا همین‌جا می‌گوییم چه شده.
setError(friendlyError(err, 'ورودِ مهمان انجام نشد، دوباره امتحان کن'));
} finally {
setLoading(null);
}
}
function onOtpChange(value: string) {
const code = digitsOnly(value, OTP_LENGTH);
setOtp(code);
if (code.length === OTP_LENGTH) void verify(code);
}
return (
<div className="flex h-full flex-col bg-page">
<header className="rounded-b-[32px] bg-coral px-5 pb-6 pt-safe text-white">
<div className="pt-8">
{onCancel && (
<button type="button" aria-label="بازگشت" onClick={onCancel} className="mb-3 flex h-9 w-9 items-center justify-center rounded-full bg-white/15">
<ChevronRight size={18} strokeWidth={2.5} />
</button>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-black">{upgrade ? 'ثبتِ شماره‌ی موبایل' : 'ورود به تدریسینو'}</h1>
<p className="mt-1 text-sm text-white/75">
{step === 'phone'
? upgrade ? 'امتیازها و پیشرفتت سرِ جاش می‌مونه' : 'شماره‌ی موبایلت رو وارد کن'
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`}
</p>
</div>
<div className="rounded-2xl bg-white/15 p-2">
<SeyarMascot char="sigma" mood="happy" size={68} />
</div>
</div>
</div>
</header>
<form
className="flex flex-1 flex-col gap-5 overflow-y-auto px-5 py-6"
onSubmit={(e) => {
e.preventDefault();
void (step === 'phone' ? requestOtp() : verify());
}}
>
{step === 'phone' ? (
<label className="block">
<span className="mb-2 block text-sm font-bold text-ink">شماره موبایل</span>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
autoComplete="tel"
autoFocus
className={`w-full rounded-2xl border-2 bg-white px-4 py-4 text-left text-base font-medium tracking-wider text-ink outline-none transition-colors placeholder:text-faint ${
error ? 'border-red-400' : phone ? 'border-coral' : 'border-line'
}`}
/>
</label>
) : (
<div>
<label className="block">
<span className="mb-2 block text-sm font-bold text-ink">کدِ تأیید</span>
<input
ref={otpInput}
value={otp}
onChange={(e) => onOtpChange(e.target.value)}
placeholder="––––––"
dir="ltr"
inputMode="numeric"
autoComplete="one-time-code"
className={`w-full rounded-2xl border-2 bg-white px-4 py-4 text-center text-2xl font-black tracking-[0.5em] text-ink outline-none transition-colors placeholder:text-faint ${
error ? 'border-red-400' : otp ? 'border-coral' : 'border-line'
}`}
/>
</label>
{devOtp && (
<p className="mt-2 text-xs text-muted">
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
</p>
)}
<div className="mt-3 flex items-center justify-between text-xs font-bold">
<button type="button" className="flex items-center gap-1 text-coral" onClick={() => { setStep('phone'); setError(''); setDevOtp(null); }}>
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
</button>
<button type="button" disabled={resendIn > 0 || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
</button>
</div>
</div>
)}
{error && <InlineError>{error}</InlineError>}
<div className="mt-auto flex flex-col gap-3">
<PrimaryButton
type="submit"
loading={loading === 'otp' || loading === 'login'}
disabled={step === 'phone' ? !phoneValid : !otpValid}
>
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
</PrimaryButton>
{!upgrade && (
<button
type="button"
onClick={() => void continueAsGuest()}
disabled={loading !== null}
className="py-2 text-center text-sm font-bold text-muted disabled:opacity-40"
>
{loading === 'guest' ? 'در حالِ ورود…' : 'فعلاً مهمان باشم'}
</button>
)}
</div>
</form>
</div>
);
}

96
src/screens/MapScreen.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { motion } from 'motion/react';
import { Check } from 'lucide-react';
import { useAppData } from '@/state/AppData';
import { unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
/** نقشه‌ی فصل‌های پایه: هر خانه یک فصل است. */
export function MapScreen() {
const { home, homeError, refreshHome, gradeTitle } = useAppData();
const nav = useNav();
const units = home?.units ?? [];
const points = zigzagPositions(units.length, 150);
const height = points.length ? points[points.length - 1].y + 200 : 400;
const overall = Math.round(home?.stats.progressPercent ?? 0);
return (
<Screen className="bg-[#f7f8ff]">
<ScreenHeader
title="نقشه‌ی فصل‌ها"
subtitle={`${gradeTitle ? `${gradeTitle} · ` : ''}${toFa(overall)}٪ از کلِ مسیر`}
color="#91ADC6"
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
>
<div className="mt-4">
<ProgressBar percent={overall} color="#fff" track="rgba(255,255,255,.25)" />
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم فصل‌ها رو میارم…" />
) : units.length === 0 ? (
<EmptyState text="هنوز فصلی برای این پایه ثبت نشده" />
) : (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath
points={points}
height={height}
colorOf={(i) => (units[i].nodes.some((n) => n.status !== 'locked') ? unitColor(i) : null)}
/>
{units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i);
const complete = p.total > 0 && p.done === p.total;
return (
<motion.div
key={unit.id}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.05, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
<button
type="button"
aria-label={unit.title}
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="relative flex h-[84px] w-[84px] items-center justify-center rounded-[26px] transition-transform active:translate-y-1"
style={{
backgroundColor: complete ? color : '#fff',
border: complete ? 'none' : `4px solid ${color}`,
boxShadow: complete ? `0 6px 0 ${color}99` : `0 6px 0 ${color}40, 0 0 0 5px white`,
}}
>
{complete ? <Check size={30} color="white" strokeWidth={3.5} /> : <Icon size={30} style={{ color }} strokeWidth={2.2} />}
{!complete && p.done > 0 && (
<span className="absolute -left-2 -top-2 rounded-full border bg-white px-1.5 py-0.5 text-[10px] font-black shadow" style={{ color, borderColor: color }}>
{toFa(p.percent)}٪
</span>
)}
</button>
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
</div>
</motion.div>
);
})}
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 150 }}>
<SeyarMascot char="pi" mood={overall >= 100 ? 'excited' : 'happy'} size={72} bob />
<div className="mt-1 whitespace-nowrap rounded-full border border-line bg-white px-3 py-1 text-[10px] font-bold text-[#7857E0] shadow-sm">
{overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همین‌جا منتظرتم'}
</div>
</div>
</div>
)}
</div>
</Screen>
);
}

View File

@@ -0,0 +1,99 @@
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react';
import { api, type NotificationItem } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { relativeTime } from '@/lib/format';
const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' },
lesson_completed: { icon: Check, color: '#5FBF8F', bg: '#EAF8F0' },
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
family_link_accepted: { icon: Users, color: '#FF7A3D', bg: '#FFF1E6' },
weekly_report_ready: { icon: FileText, color: '#91ADC6', bg: '#EAF2F6' },
};
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };
export function NotificationsScreen() {
const { setUnreadCount } = useAppData();
const toast = useToast();
const list = useAsync(async () => {
const r = await api.notifications.list();
setUnreadCount(r.unreadCount);
return r.items;
}, []);
const items = list.data ?? [];
const unread = items.filter((n) => !n.isRead).length;
const markLocal = (predicate: (n: NotificationItem) => boolean) => {
list.setData((prev) => prev?.map((n) => (predicate(n) ? { ...n, isRead: true } : n)));
};
async function markOne(n: NotificationItem) {
if (n.isRead) return;
markLocal((x) => x.id === n.id);
setUnreadCount(Math.max(0, unread - 1));
api.notifications.markRead(n.id).catch(() => {});
}
async function markAll() {
markLocal(() => true);
setUnreadCount(0);
try {
await api.notifications.markAllRead();
} catch {
toast('ثبت نشد؛ دوباره امتحان کن', 'error');
list.reload();
}
}
return (
<Screen>
<ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'idle', size: 54 }}>
{unread > 0 && (
<button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold">
همه خوانده شد
</button>
)}
</ScreenHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">
{list.loading ? (
<LoadingState text="دارم اعلان‌ها رو میارم…" />
) : list.error ? (
<ErrorState error={list.error} onRetry={list.reload} />
) : items.length === 0 ? (
<EmptyState text="فعلاً خبری نیست" />
) : (
<ul className="flex flex-col gap-2">
{items.map((n) => {
const s = styleOf(n.type);
const Icon = s.icon;
return (
<li key={n.id}>
<button
type="button"
onClick={() => void markOne(n)}
className={`flex w-full items-start gap-3 rounded-2xl p-3.5 text-right ${n.isRead ? 'border border-line bg-white' : 'border border-indigo/20 bg-[#EEF1FF]'}`}
>
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full" style={{ backgroundColor: s.bg }}>
<Icon size={18} style={{ color: s.color }} />
</span>
<span className="min-w-0 flex-1">
<span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span>
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body">{n.body}</span>}
<span className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span>
</span>
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}
</button>
</li>
);
})}
</ul>
)}
</div>
</Screen>
);
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronLeft, ChevronRight, Sparkles } from 'lucide-react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
{
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
sub: 'از پایه‌ی سوم تا دوازدهم',
title: 'به تدریسینو خوش اومدی!',
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو می‌بریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.',
},
{
char: 'delta', mood: 'excited', bg: '#FF7A3D',
sub: 'هر روز یه کم',
title: 'امتیاز بگیر، زنجیره بساز',
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه می‌گیری، روزهای پیاپی‌ت شمرده می‌شه و تو جدولِ امتیازها بالا می‌ری.',
},
{
char: 'pi', mood: 'happy', bg: '#9B7BFF',
sub: 'هر جا گیر کردی',
title: 'از پی بپرس',
desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.',
},
];
export function OnboardingScreen({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
const slide = SLIDES[step];
const last = step === SLIDES.length - 1;
return (
<div className="relative flex h-full flex-col text-white transition-colors duration-500" style={{ backgroundColor: slide.bg }}>
{step > 0 && (
<button
type="button"
aria-label="قبلی"
onClick={() => setStep((s) => s - 1)}
className="absolute right-5 top-10 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white/15"
>
<ChevronRight size={18} strokeWidth={2.5} />
</button>
)}
<div className="flex flex-1 flex-col items-center justify-center px-8">
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="rounded-3xl border border-white/10 bg-white/10 p-6">
<SeyarMascot char={slide.char} mood={slide.mood} size={150} />
</div>
<div>
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p>
<h1 className="mb-3 text-2xl font-black leading-snug">{slide.title}</h1>
<p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="flex justify-center gap-2 pb-4" aria-hidden>
{SLIDES.map((_, i) => (
<span key={i} className={`h-2 rounded-full transition-all duration-300 ${i === step ? 'w-6 bg-white' : 'w-2 bg-white/30'}`} />
))}
</div>
<div className="flex gap-3 px-6 pb-10">
{!last && (
<button type="button" onClick={onDone} className="flex-1 rounded-2xl border border-white/25 py-3.5 text-sm font-semibold text-white/85">
رد کردن
</button>
)}
<button
type="button"
onClick={last ? onDone : () => setStep((s) => s + 1)}
style={{ color: slide.bg }}
className="flex flex-[2] items-center justify-center gap-2 rounded-2xl bg-white py-3.5 font-black shadow-lg shadow-black/20 active:scale-95"
>
{last ? <>بزن بریم! <Sparkles size={18} /></> : <>بعدی <ChevronLeft size={18} /></>}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,283 @@
import { useState, type ReactNode } from 'react';
import {
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
Pencil, Smartphone, Sparkles, Star, Trophy, Users, Volume2, VolumeX,
} from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
import { askStore } from '@/state/askStore';
import { useNav } from '@/nav/Navigator';
import type { Route } from '@/nav/routes';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
import { toFa } from '@/lib/format';
type Editing = 'name' | 'grade' | 'age' | 'logout' | null;
/** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
const { profile, gradeTitle, summary, companion } = useAppData();
const nav = useNav();
const [editing, setEditing] = useState<Editing>(null);
const [sound, setSound] = useState(soundEnabled());
const age = ageFromBirthYear(profile.birthYear);
const go = (route: Route) => () => nav.push(route);
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<header className="relative overflow-hidden rounded-b-[32px] bg-indigo px-5 pb-6 pt-safe text-white">
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="relative flex items-center gap-4 pt-8">
<div className="rounded-3xl bg-white/15 p-2">
<SeyarMascot char={companion} mood="happy" size={72} />
</div>
<div className="min-w-0 flex-1">
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
{profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" />
</button>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
{profile.isGuest ? (
<span className="rounded-full bg-[#FFD23F] px-2.5 py-0.5 font-bold text-ink">مهمان</span>
) : (
profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>
)}
</div>
</div>
</div>
<div className="relative mt-5 grid grid-cols-3 gap-2">
{[
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز' },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه' },
{ icon: Flame, value: summary?.streak.longest ?? 0, label: 'بیشترین زنجیره' },
].map(({ icon: Icon, value, label }) => (
<div key={label} className="flex flex-col items-center rounded-2xl bg-white/12 py-2.5">
<Icon size={16} className="text-white/80" />
<span className="mt-1 text-base font-black">{toFa(value)}</span>
<span className="text-[10px] text-white/70">{label}</span>
</div>
))}
</div>
</header>
<div className="flex flex-col gap-4 px-4 py-5">
{profile.isGuest && (
<button
type="button"
onClick={go({ name: 'register' })}
className="flex items-center gap-3 rounded-3xl bg-[#FFF8E1] p-4 text-right ring-2 ring-[#FFD23F]"
>
<Smartphone size={22} className="flex-shrink-0 text-gold" />
<div className="flex-1">
<div className="text-sm font-black text-ink">شماره‌ت رو ثبت کن</div>
<div className="mt-0.5 text-xs leading-6 text-body">تا اگه گوشیت عوض شد، امتیازها و پیشرفتت گم نشه.</div>
</div>
<ChevronLeft size={18} className="text-muted" />
</button>
)}
<Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row icon={CalendarDays} label="سن" value={age ? `${toFa(age)} سال` : 'وارد نشده'} onClick={() => setEditing('age')} />
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
</Menu>
<Menu>
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} />
</Menu>
<Menu>
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />}
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />}
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
<Row
icon={sound ? Volume2 : VolumeX}
label="صداها"
value={sound ? 'روشن' : 'خاموش'}
onClick={() => {
setSoundEnabled(!sound);
setSound(!sound);
}}
/>
</Menu>
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500">
<LogOut size={16} /> خروج از حساب
</button>
</div>
</div>
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} />
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
</Screen>
);
}
function Menu({ children }: { children: ReactNode }) {
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
}
function Row({ icon: Icon, label, value, onClick }: { icon: typeof Bell; label: string; value?: string; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft">
<Icon size={18} className="flex-shrink-0 text-indigo" />
<span className="flex-1 text-sm font-bold text-ink">{label}</span>
{value && <span className="text-xs text-muted">{value}</span>}
<ChevronLeft size={16} className="text-faint" />
</button>
);
}
function NameSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, setProfile } = useAppData();
const toast = useToast();
const [name, setName] = useState(profile.name);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
if (!name.trim()) return;
setSaving(true);
setError('');
try {
setProfile(await api.profile.update({ name: name.trim() }));
toast('اسمت عوض شد', 'success');
onClose();
} catch (err) {
setError(friendlyError(err));
} finally {
setSaving(false);
}
}
return (
<Sheet open={open} onClose={onClose} title="اسمت">
<input
value={name}
onChange={(e) => setName(e.target.value.slice(0, 40))}
className="mb-4 w-full rounded-2xl border-2 border-line px-4 py-3.5 text-sm outline-none focus:border-coral"
/>
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
<PrimaryButton loading={saving} disabled={!name.trim()} onClick={() => void save()}>ذخیره</PrimaryButton>
</Sheet>
);
}
function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, grades, setProfile, refreshHome, refreshSummary } = useAppData();
const toast = useToast();
const [saving, setSaving] = useState<number | null>(null);
async function choose(gradeId: number) {
if (gradeId === profile.gradeId) return onClose();
setSaving(gradeId);
try {
await api.profile.setGrade(gradeId);
setProfile({ ...profile, gradeId });
askStore.reset(null);
await Promise.all([refreshHome(), refreshSummary()]);
toast('پایه‌ت عوض شد', 'success');
onClose();
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
setSaving(null);
}
}
return (
<Sheet open={open} onClose={onClose} title="پایه‌ی تحصیلی">
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته می‌شه؛ با برگشتن، سرِ جاشه.</p>
<div className="grid grid-cols-2 gap-2.5">
{grades.map((g) => {
const on = g.id === profile.gradeId;
return (
<button
key={g.id}
type="button"
disabled={saving !== null}
onClick={() => void choose(g.id)}
className={`flex items-center justify-between rounded-2xl border-2 px-3.5 py-3 text-sm font-bold ${on ? 'border-coral bg-coral text-white' : 'border-line text-body'}`}
>
{g.title}
{on && <Check size={16} strokeWidth={3} />}
{saving === g.id && <span className="h-4 w-4 animate-spin rounded-full border-2 border-coral border-t-transparent" />}
</button>
);
})}
</div>
</Sheet>
);
}
function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, setProfile } = useAppData();
const toast = useToast();
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
const [saving, setSaving] = useState(false);
async function save() {
if (!age) return;
setSaving(true);
try {
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) }));
toast('ذخیره شد', 'success');
onClose();
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
setSaving(false);
}
}
return (
<Sheet open={open} onClose={onClose} title="چند سالته؟">
<div className="mb-5">
<AgePicker value={age} onChange={setAge} />
</div>
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton>
</Sheet>
);
}
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const { profile } = useAppData();
const [busy, setBusy] = useState(false);
async function logout() {
setBusy(true);
try {
await api.auth.logout();
} catch {
/* حافظه در هر صورت پاک شده؛ کاربر بیرون است */
}
askStore.clear();
onSignedOut();
}
return (
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
<p className="mb-5 text-sm leading-7 text-muted">
{profile.isGuest
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمی‌گردونه، ولی از گوشیِ دیگه نه. برای امن‌تر شدن، اول شماره‌ت رو ثبت کن.'
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
</p>
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
</div>
</Sheet>
);
}

466
src/screens/QuizScreen.tsx Normal file
View File

@@ -0,0 +1,466 @@
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import confetti from 'canvas-confetti';
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
import { api, ApiError, type AnswerResult, type Hearts, type LessonComplete, type Question, type ReportReason } from '@/api';
import { useAppData } from '@/state/AppData';
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { newIdempotencyKey } from '@/api/http';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarMood } from '@/mascots/seyar';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { timeUntil, toFa } from '@/lib/format';
import { sounds } from '@/lib/sound';
type Phase =
| { kind: 'loading' }
| { kind: 'error'; error: unknown }
| { kind: 'playing' }
| { kind: 'noHearts'; nextRefillAt: string | null }
| { kind: 'finishing' }
| { kind: 'finishError'; error: unknown }
| { kind: 'result'; result: LessonComplete };
/** تمرینِ یک درس: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه. */
export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
const { home, companion, refreshHome, refreshSummary } = useAppData();
const nav = useNav();
const toast = useToast();
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
const [questions, setQuestions] = useState<Question[]>([]);
const [hearts, setHearts] = useState<Hearts | null>(null);
const [index, setIndex] = useState(0);
const [selected, setSelected] = useState<string | null>(null);
const [answer, setAnswer] = useState<AnswerResult | null>(null);
const [submitting, setSubmitting] = useState(false);
const [correctCount, setCorrectCount] = useState(0);
const [confirmExit, setConfirmExit] = useState(false);
const [reporting, setReporting] = useState(false);
const [reload, setReload] = useState(0);
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمی‌شود.
const answerKeys = useRef(new Map<string, string>());
const completeKey = useRef(newIdempotencyKey());
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const question = questions[index];
const isLast = index === questions.length - 1;
const unlimited = hearts?.isUnlimited ?? false;
const heartsLeft = hearts?.currentHearts ?? 5;
const outOfHearts = !unlimited && hearts !== null && heartsLeft <= 0;
useEffect(() => {
let alive = true;
setPhase({ kind: 'loading' });
Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)])
.then(([qs, h]) => {
if (!alive) return;
setQuestions(qs);
setHearts(h);
setIndex(0);
setSelected(null);
setAnswer(null);
setCorrectCount(0);
answerKeys.current.clear();
completeKey.current = newIdempotencyKey();
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt });
else setPhase({ kind: 'playing' });
})
.catch((error: unknown) => alive && setPhase({ kind: 'error', error }));
return () => {
alive = false;
};
}, [lessonId, reload]);
async function choose(optionId: string) {
if (!question || selected !== null || submitting) return;
setSelected(optionId);
setSubmitting(true);
let key = answerKeys.current.get(question.id);
if (!key) {
key = newIdempotencyKey();
answerKeys.current.set(question.id, key);
}
try {
const result = await api.lesson.answer(question.id, optionId, key);
setAnswer(result);
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
if (result.correct) {
setCorrectCount((c) => c + 1);
sounds.correct();
} else {
sounds.wrong();
}
} catch (err) {
setSelected(null);
if (err instanceof ApiError && err.code === 'no_hearts') {
setPhase({ kind: 'noHearts', nextRefillAt: (err.body.nextRefillAt as string | null) ?? null });
} else {
toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error');
}
} finally {
setSubmitting(false);
}
}
async function next() {
if (outOfHearts && !isLast) {
const h = await api.hearts().catch(() => null);
setPhase({ kind: 'noHearts', nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null });
return;
}
if (!isLast) {
setIndex((i) => i + 1);
setSelected(null);
setAnswer(null);
return;
}
await finish();
}
async function finish() {
setPhase({ kind: 'finishing' });
try {
const result = await api.lesson.complete(
lessonId,
{ correctCount, totalQuestions: questions.length },
completeKey.current,
);
setPhase({ kind: 'result', result });
sounds.finish();
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
void refreshHome();
void refreshSummary();
} catch (error) {
setPhase({ kind: 'finishError', error });
}
}
const askWhy = () =>
nav.push({ name: 'ask', lessonId, questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟' });
/* ─── حالت‌های غیرِ بازی ─────────────────────────────────────────────── */
if (phase.kind === 'loading') return <Screen><LoadingState text="دارم سوال‌ها رو میارم…" /></Screen>;
if (phase.kind === 'error') {
return (
<Screen>
<div className="flex flex-1 flex-col justify-center">
<ErrorState error={phase.error} onRetry={() => setReload((n) => n + 1)} />
<div className="px-8"><SecondaryButton onClick={nav.back}>برگشت</SecondaryButton></div>
</div>
</Screen>
);
}
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />;
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
return (
<Screen>
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<SeyarMascot char={companion} mood={phase.kind === 'finishing' ? 'happy' : 'sad'} size={96} />
{phase.kind === 'finishing' ? (
<p className="text-sm text-muted">دارم نتیجه‌ت رو ثبت می‌کنم…</p>
) : (
<>
<InlineError>نتیجه‌ی این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
<SecondaryButton onClick={nav.back}>بی‌خیال، برگرد</SecondaryButton>
</>
)}
</div>
</Screen>
);
}
if (!question) {
return (
<Screen>
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
<ErrorState text="این درس هنوز سوالی نداره" />
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
</div>
</Screen>
);
}
/* ─── خودِ بازی ───────────────────────────────────────────────────────── */
const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad';
const bubble = !answer
? 'جوابِ درست رو انتخاب کن!'
: answer.correct
? 'آفرین! درست بود ⚡'
: outOfHearts
? 'قلب‌هات تموم شد؛ یه کم استراحت کنیم.'
: 'اشکالی نداره، جوابِ درست رو ببین.';
const shortOptions = question.options.every((o) => o.value.length <= 14);
const optionClass = (id: string) => {
if (!answer) return selected === id ? 'border-coral bg-[#fff4f2] text-ink' : 'border-line bg-white text-ink';
if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700';
if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500';
return 'border-line bg-white text-faint';
};
return (
<Screen>
<div className="flex-shrink-0 border-b border-line bg-white px-5 pb-4 pt-safe">
<div className="flex items-center gap-3 pt-5">
<button type="button" aria-label="خروج از تمرین" onClick={() => setConfirmExit(true)} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-soft">
<X size={15} className="text-coral" />
</button>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
</div>
<span className="w-10 flex-shrink-0 text-left text-xs font-bold text-muted">
{toFa(index + 1)}/{toFa(questions.length)}
</span>
</div>
<HeartsRow hearts={hearts} />
</div>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<div className="flex items-center gap-3">
<AnimatePresence mode="wait">
<motion.div key={mood} initial={{ scale: 0.7, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.7, opacity: 0 }} className="rounded-3xl border-2 border-line bg-white p-2">
<SeyarMascot char={companion} mood={mood} size={64} />
</motion.div>
</AnimatePresence>
<div className={`flex-1 rounded-2xl border px-4 py-3 text-xs font-bold leading-6 ${
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
}`}>
{bubble}
</div>
</div>
<div className="rounded-3xl border border-line bg-white p-5 shadow-sm">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted">سوالِ {toFa(index + 1)}</span>
<button type="button" onClick={() => setReporting(true)} className="flex items-center gap-1 text-[11px] font-bold text-faint">
<Flag size={12} /> گزارشِ ایراد
</button>
</div>
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink">{question.prompt}</h2>
</div>
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
{question.options.map((option) => (
<motion.button
key={option.id}
type="button"
onClick={() => void choose(option.id)}
disabled={selected !== null && !answer}
whileTap={{ scale: selected === null ? 0.95 : 1 }}
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
>
<span dir="auto">{option.value}</span>
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
{answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />}
</motion.button>
))}
</div>
{answer && (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="mt-1 flex flex-col gap-2.5">
{!answer.correct && (
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
<Brain size={16} /> از پی بپرس چرا غلط شد
</button>
)}
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
{isLast ? 'پایانِ تمرین' : 'سوالِ بعدی'} <ChevronLeft size={18} />
</PrimaryButton>
</motion.div>
)}
</div>
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
<p className="mb-5 text-sm leading-7 text-muted">جواب‌هایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.</p>
<div className="flex flex-col gap-2.5">
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه می‌دم</PrimaryButton>
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
</div>
</Sheet>
<ReportSheet open={reporting} questionId={question.id} onClose={() => setReporting(false)} />
</Screen>
);
}
function HeartsRow({ hearts }: { hearts: Hearts | null }) {
if (hearts?.isUnlimited) {
return (
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral">
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلبِ نامحدود
</div>
);
}
const max = hearts?.maxHearts ?? 5;
const current = hearts?.currentHearts ?? max;
return (
<div className="mt-3 flex items-center justify-center gap-2" aria-label={`${toFa(current)} قلب از ${toFa(max)}`}>
{Array.from({ length: max }, (_, i) => (
<span key={i} className="relative h-5 w-5">
<Heart size={20} className="absolute inset-0 text-[#e5e1f7]" />
<AnimatePresence>
{i < current && (
<motion.span
className="absolute inset-0"
exit={{ scale: [1, 1.35, 0], rotate: [0, -22, 18, 0], y: [0, -4, 10], opacity: [1, 1, 0] }}
transition={{ duration: 0.55 }}
>
<Heart size={20} className="text-coral" fill="#ED6956" />
</motion.span>
)}
</AnimatePresence>
</span>
))}
</div>
);
}
function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
const nav = useNav();
return (
<Screen>
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<SeyarMascot char="pi" mood="depressed" size={110} />
<h2 className="text-xl font-black text-ink">قلب‌هات تموم شد</h2>
<p className="text-sm leading-7 text-muted">
{nextRefillAt ? <>قلبِ بعدی تا <b className="text-coral">{timeUntil(nextRefillAt)}</b> دیگه پر می‌شه.</> : 'کمی صبر کن تا قلب‌هات دوباره پر بشن.'}
<br />
با اشتراکِ ویژه قلب‌ها نامحدودن.
</p>
<PrimaryButton onClick={() => nav.switchTab('subscription')}>دیدنِ اشتراک</PrimaryButton>
<SecondaryButton onClick={nav.back}>باشه، بعداً</SecondaryButton>
</div>
</Screen>
);
}
function Result({
result, total, correct, lessonId, nodeId, color,
}: { result: LessonComplete; total: number; correct: number; lessonId: number; nodeId?: string; color: string }) {
const nav = useNav();
const { companion } = useAppData();
const [loadingNext, setLoadingNext] = useState(false);
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخه‌ی تازه‌ی نقشه می‌خوانیم.
async function goNext() {
setLoadingNext(true);
try {
const fresh = await api.home();
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId);
const next = ref ? nextLessonOf(ref.node) : null;
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
} else {
nav.back();
}
} catch {
nav.back();
}
}
return (
<Screen>
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-8 text-center">
<SeyarMascot char={companion} mood={result.stars >= 2 ? 'excited' : 'happy'} size={120} />
<div className="flex gap-1.5" aria-label={`${toFa(result.stars)} ستاره`}>
{[1, 2, 3].map((s) => (
<motion.span key={s} initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2 + s * 0.15, type: 'spring' }}>
<Star size={40} className={s <= result.stars ? 'text-[#FFC53D]' : 'text-[#e5e1f7]'} fill={s <= result.stars ? '#FFC53D' : '#e5e1f7'} />
</motion.span>
))}
</div>
<div>
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
</div>
<div className="flex w-full gap-3">
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
<Star size={18} className="text-gold" />
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
<span className="text-[10px] text-muted">امتیاز</span>
</div>
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
<Coins size={18} className="text-violet" />
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
<span className="text-[10px] text-muted">سکه</span>
</div>
</div>
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
ادامه <ChevronLeft size={18} />
</PrimaryButton>
</div>
</Screen>
);
}
const REASONS: { id: ReportReason; label: string }[] = [
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
{ id: 'typo', label: 'غلطِ تایپی داره' },
{ id: 'too_hard', label: 'برای این درس خیلی سخته' },
{ id: 'other', label: 'یه چیزِ دیگه' },
];
/** گزارشِ سوالِ ایراددار؛ مستقیم به صفِ بررسیِ محتوا می‌رود. */
function ReportSheet({ open, questionId, onClose }: { open: boolean; questionId: string; onClose: () => void }) {
const toast = useToast();
const [reason, setReason] = useState<ReportReason | null>(null);
const [note, setNote] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
if (open) {
setReason(null);
setNote('');
}
}, [open]);
async function send() {
if (!reason) return;
setSending(true);
try {
const r = await api.lesson.report(questionId, reason, note.trim());
toast(r.alreadyReported ? 'قبلاً گزارشش رو داده بودی؛ ممنون!' : 'ممنون! گزارشت رسید و بررسی می‌شه.', 'success');
onClose();
} catch (err) {
toast(friendlyError(err, 'گزارش ارسال نشد'), 'error');
} finally {
setSending(false);
}
}
return (
<Sheet open={open} onClose={onClose} title="این سوال چه ایرادی داره؟">
<div className="mb-4 flex flex-col gap-2">
{REASONS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setReason(r.id)}
className={`rounded-2xl border-2 px-4 py-3 text-right text-sm font-bold ${reason === r.id ? 'border-coral bg-[#fff4f2] text-coral' : 'border-line text-body'}`}
>
{r.label}
</button>
))}
</div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder="توضیحِ بیشتر (اختیاری)"
rows={2}
className="mb-4 w-full resize-none rounded-2xl border-2 border-line px-4 py-3 text-sm outline-none focus:border-coral"
/>
<PrimaryButton disabled={!reason} loading={sending} onClick={() => void send()}>ارسالِ گزارش</PrimaryButton>
</Sheet>
);
}

128
src/screens/SetupScreen.tsx Normal file
View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { Check, ChevronLeft } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api';
import { InlineError, PrimaryButton } from '@/components/ui';
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
/** «بیا با هم آشنا بشیم»: اسم، سن و پایه، بارِ اولِ ورود. */
export function SetupScreen({
profile,
grades,
onDone,
}: {
profile: UserProfile;
grades: Grade[];
onDone: (profile: UserProfile) => void;
}) {
const [name, setName] = useState(profile.name);
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
const [gradeId, setGradeId] = useState<number | null>(profile.gradeId);
const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const nameMissing = touched && !name.trim();
const gradeMissing = touched && !gradeId;
async function submit() {
setTouched(true);
if (!name.trim() || !gradeId || saving) return;
setSaving(true);
setError('');
try {
await api.profile.setGrade(gradeId);
const updated = await api.profile.update({
name: name.trim(),
...(age ? { birthYear: birthYearFromAge(age) } : {}),
});
onDone({ ...updated, gradeId });
} catch (err) {
setError(friendlyError(err, 'ذخیره نشد، دوباره امتحان کن'));
} finally {
setSaving(false);
}
}
return (
<div className="flex h-full flex-col bg-page">
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-7 pt-safe text-white">
<div className="absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="relative flex items-center justify-between pt-8">
<div>
<div className="mb-1 text-xs font-bold text-white/75">فقط چندتا سؤالِ کوچیک</div>
<h1 className="text-2xl font-black">بیا با هم آشنا بشیم!</h1>
</div>
<div className="rounded-3xl bg-white/15 p-2">
<SeyarMascot char="sigma" mood="happy" size={68} />
</div>
</div>
</header>
<div className="flex flex-1 flex-col gap-6 overflow-y-auto px-5 py-6">
<label className="block">
<span className="mb-2 block text-sm font-black text-ink">اسمت چیه؟</span>
<input
value={name}
onChange={(e) => setName(e.target.value.slice(0, 40))}
placeholder="مثلاً سارا یا علی"
autoComplete="given-name"
className={`w-full rounded-2xl border-2 bg-white px-4 py-4 text-sm font-medium text-ink outline-none placeholder:text-faint ${
nameMissing ? 'border-red-400 bg-red-50' : name ? 'border-coral' : 'border-line'
}`}
/>
{nameMissing && <span className="mt-1.5 block text-xs text-red-500">اسمت رو بنویس</span>}
</label>
<div>
<span className="mb-2 block text-sm font-black text-ink">
چند سالته؟ <span className="text-xs font-medium text-muted">(اختیاری)</span>
</span>
<AgePicker value={age} onChange={setAge} />
</div>
<div>
<span className="mb-3 block text-sm font-black text-ink">کدوم پایه‌ای؟</span>
<div className="grid grid-cols-2 gap-3">
{grades.map((g) => {
const selected = gradeId === g.id;
return (
<button
key={g.id}
type="button"
onClick={() => setGradeId(g.id)}
aria-pressed={selected}
className={`flex items-center gap-3 rounded-2xl border-2 px-3.5 py-3.5 text-right transition-all active:scale-[0.97] ${
selected
? 'border-coral bg-coral text-white shadow-lg shadow-coral/25'
: gradeMissing
? 'border-red-300 bg-red-50 text-body'
: 'border-line bg-white text-body'
}`}
>
<span
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-sm font-black ${selected ? 'bg-white/20 text-white' : 'bg-soft'}`}
style={selected ? undefined : { color: g.color }}
>
{toFa(g.number ?? g.id)}
</span>
<span className="flex-1 text-sm font-bold">{g.title}</span>
{selected && <Check size={18} strokeWidth={3} />}
</button>
);
})}
</div>
{gradeMissing && <span className="mt-2 block text-xs text-red-500">پایه‌ت رو انتخاب کن</span>}
</div>
{error && <InlineError>{error}</InlineError>}
<PrimaryButton onClick={() => void submit()} loading={saving} className="mt-auto">
شروعِ یادگیری <ChevronLeft size={19} strokeWidth={3} />
</PrimaryButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { motion } from 'motion/react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
/** تا وقتی نشستِ قبلی بررسی می‌شود نمایش داده می‌شود. */
export function SplashScreen() {
return (
<div className="flex h-full flex-col items-center justify-center bg-rose">
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
className="flex flex-col items-center gap-4"
>
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
<SeyarMascot char="sigma" mood="happy" size={130} />
</div>
<h1 className="text-3xl font-black text-white">تدریسینو</h1>
<p className="text-sm text-white/75">ریاضی، قدم‌به‌قدم، با سه‌یار</p>
</motion.div>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.8, repeat: Infinity, ease: 'linear' }}
className="absolute bottom-20 h-6 w-6 rounded-full border-2 border-white/25 border-t-white"
/>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { Flame } from 'lucide-react';
import { api } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
const WEEKS = 6;
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
/** YYYY-MM-DD به وقتِ محلی (سرور روزها را همین‌طور می‌دهد). */
const isoDay = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
/** شش هفته‌ی اخیر، هفته از شنبه شروع می‌شود. */
function recentWeeks(today = new Date()): Date[][] {
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const offsetFromSaturday = (end.getDay() + 1) % 7;
const start = new Date(end);
start.setDate(end.getDate() - offsetFromSaturday - (WEEKS - 1) * 7);
return Array.from({ length: WEEKS }, (_, w) =>
Array.from({ length: 7 }, (_, d) => {
const day = new Date(start);
day.setDate(start.getDate() + w * 7 + d);
return day;
}),
);
}
const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric' }).format(d);
export function StreakScreen() {
const calendar = useAsync(() => api.streakCalendar(), []);
const active = new Set(calendar.data?.activeDays ?? []);
const today = isoDay(new Date());
const weeks = recentWeeks();
return (
<Screen>
<ScreenHeader title="زنجیره‌ی روزها" subtitle="هر روز حداقل یه درس، زنجیره‌ت رو زنده نگه می‌داره" color="#FF7A3D" back mascot={{ char: 'delta', mood: 'excited', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{calendar.loading ? (
<LoadingState char="delta" />
) : calendar.error || !calendar.data ? (
<ErrorState error={calendar.error} onRetry={calendar.reload} />
) : (
<>
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'زنجیره‌ی فعلی', value: calendar.data.currentStreak },
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
].map((s) => (
<div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4">
<Flame size={24} className="text-orange" fill="#FF7A3D" />
<span className="mt-1 text-2xl font-black text-ink">{toFa(s.value)}</span>
<span className="text-xs text-muted">{s.label} (روز)</span>
</div>
))}
</div>
<div className="rounded-3xl border border-line bg-white p-4">
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
</div>
<div className="flex flex-col gap-1.5">
{weeks.map((week, w) => (
<div key={w} className="grid grid-cols-7 gap-1.5">
{week.map((day) => {
const key = isoDay(day);
const on = active.has(key);
const future = key > today;
return (
<span
key={key}
title={key}
className={`flex aspect-square items-center justify-center rounded-xl text-[11px] font-bold ${
on ? 'bg-orange text-white' : future ? 'text-faint/50' : 'bg-soft text-muted'
} ${key === today ? 'ring-2 ring-orange ring-offset-1' : ''}`}
>
{on ? <Flame size={14} fill="white" /> : dayOfMonth(day)}
</span>
);
})}
</div>
))}
</div>
</div>
</>
)}
</ScreenBody>
</Screen>
);
}

View File

@@ -0,0 +1,192 @@
import { useState } from 'react';
import { Check, Crown, Heart, Ticket } from 'lucide-react';
import { api, type Plan } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { formatJalaliDate, formatToman, toFa } from '@/lib/format';
function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یک‌ماهه';
if (days >= 360 && days <= 370) return 'یک‌ساله';
if (days === 90) return 'سه‌ماهه';
return days ? `${toFa(days)} روزه` : '';
}
export function SubscriptionScreen() {
const plans = useAsync(() => api.subscription.plans(), []);
const status = useAsync(() => api.subscription.status(), []);
const [buying, setBuying] = useState<Plan | null>(null);
const activePlanId = status.data?.active ? status.data.planId : null;
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<ScreenHeader title="اشتراکِ ویژه" subtitle="یادگیری بدونِ وقفه" color="#ED6956" mascot={{ char: 'sigma', mood: 'excited', size: 60 }} />
<div className="flex flex-col gap-4 px-4 py-5">
{status.data?.active && (
<div className="flex items-center gap-3 rounded-3xl border-2 border-mint bg-[#EAF8F0] p-4">
<Crown size={22} className="flex-shrink-0 text-mint" />
<div className="text-sm leading-7 text-ink">
<b>اشتراکت فعاله.</b>
{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}.</>}
<div className="text-xs text-muted">اگه دوباره بخری، از روزِ پایانِ همین اشتراک تمدید می‌شه.</div>
</div>
</div>
)}
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
<Heart size={22} className="flex-shrink-0 text-coral" fill="#ED6956" />
<p className="text-sm leading-7 text-body">با اشتراک، قلب‌هات هیچ‌وقت تموم نمی‌شن و هر چقدر بخوای تمرین می‌کنی.</p>
</div>
{plans.loading ? (
<LoadingState text="دارم پلن‌ها رو میارم…" />
) : plans.error ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !plans.data?.length ? (
<EmptyState text="فعلاً پلنی برای خرید تعریف نشده" />
) : (
plans.data.map((plan) => {
const featured = plan.popular;
const isCurrent = plan.id === activePlanId;
return (
<div key={plan.id} className="relative pt-3">
{plan.badge && (
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">
{plan.badge}
</span>
)}
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-black">{plan.title}</h3>
<p className={`mt-0.5 text-xs ${featured ? 'text-white/75' : 'text-muted'}`}>{durationLabel(plan.durationDays)}</p>
</div>
<div className="text-left">
<p className="text-base font-black">{plan.price === 0 ? 'رایگان' : formatToman(plan.price)}</p>
{plan.discountPercent > 0 && (
<span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-black ${featured ? 'bg-white/20' : 'bg-[#fde9e5] text-coral'}`}>
{toFa(plan.discountPercent)}٪ تخفیف
</span>
)}
</div>
</div>
{plan.features.length > 0 && (
<ul className="mb-4 flex flex-col gap-2">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm">
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
</li>
))}
</ul>
)}
<button
type="button"
disabled={plan.price === 0 || isCurrent}
onClick={() => setBuying(plan)}
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-60 ${
featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'
}`}
>
{isCurrent ? 'اشتراکِ فعلیِ تو' : plan.price === 0 ? 'رایگان' : status.data?.active ? 'تمدید با این پلن' : 'انتخابِ این پلن'}
</button>
</div>
</div>
);
})
)}
<p className="pb-2 text-center text-[11px] leading-6 text-faint">پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.</p>
</div>
</div>
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); status.reload(); }} />
</Screen>
);
}
/**
* خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود،
* و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را
* Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است.
*/
function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; onClose: () => void; onPaidWithCode: () => void }) {
const [code, setCode] = useState('');
const [showCode, setShowCode] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [freeDone, setFreeDone] = useState(false);
async function pay() {
if (!plan || busy) return;
setBusy(true);
setError('');
try {
const order = await api.subscription.purchase(plan.id);
if (code.trim()) {
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
if (redeemed.orderStatus === 'paid') {
setFreeDone(true);
setBusy(false);
return;
}
}
window.location.assign(order.paymentUrl);
} catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
setBusy(false);
}
}
const close = () => {
if (freeDone) onPaidWithCode();
else onClose();
setCode('');
setShowCode(false);
setError('');
setFreeDone(false);
};
return (
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'اشتراکت فعال شد! 🎉' : plan?.title}>
{freeDone ? (
<>
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و اشتراکت همین الان فعال شد.</p>
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
</>
) : plan && (
<>
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3">
<span className="text-sm text-body">مبلغ</span>
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
</div>
{showCode ? (
<label className="mb-4 block">
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
<input
value={code}
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
dir="ltr"
autoCapitalize="characters"
placeholder="CODE"
className="w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-coral"
/>
<span className="mt-1.5 block text-[11px] text-muted">مبلغِ نهایی بعد از اعمالِ کد در درگاه نشون داده می‌شه.</span>
</label>
) : (
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
<Ticket size={15} /> کدِ تخفیف دارم
</button>
)}
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
</>
)}
</Sheet>
);
}

View File

@@ -0,0 +1,197 @@
import { useState } from 'react';
import { Bug, MessageCircle, Send } from 'lucide-react';
import { api, type Ticket } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { KEYS, store } from '@/lib/storage';
import { useToast } from '@/components/Toast';
import { InlineError, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
const CATEGORIES = [
{ id: 'learning', label: 'درس و سوال‌ها' },
{ id: 'payment', label: 'پرداخت و اشتراک' },
{ id: 'account', label: 'حساب و ورود' },
{ id: 'other', label: 'چیزِ دیگه' },
];
const STATUS_LABEL: Record<string, string> = {
open: 'باز',
in_progress: 'در حالِ بررسی',
answered: 'پاسخ داده شد',
closed: 'بسته',
};
/** سرور فهرستِ تیکت‌ها را نمی‌دهد؛ شناسه‌ها را همین‌جا نگه می‌داریم. */
function savedTicketIds(): string[] {
try {
return JSON.parse(store.get(KEYS.ticketIds) ?? '[]') as string[];
} catch {
return [];
}
}
export function SupportScreen() {
const toast = useToast();
const [mode, setMode] = useState<'ticket' | 'bug'>('ticket');
const [category, setCategory] = useState('learning');
const [subject, setSubject] = useState('');
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const [ticketIds, setTicketIds] = useState(savedTicketIds);
async function submit() {
if (!subject.trim() || !text.trim()) return;
setSending(true);
setError('');
try {
if (mode === 'bug') {
await api.support.bugReport(subject.trim(), text.trim());
toast('گزارشت رسید؛ ممنون!', 'success');
} else {
const t = await api.support.createTicket(subject.trim(), category);
await api.support.reply(t.ticketId, text.trim());
const ids = [t.ticketId, ...ticketIds].slice(0, 20);
store.set(KEYS.ticketIds, JSON.stringify(ids));
setTicketIds(ids);
toast('پیامت رسید؛ جواب همین‌جا و در اعلان‌ها میاد.', 'success');
}
setSubject('');
setText('');
} catch (err) {
setError(friendlyError(err, 'ارسال نشد، دوباره امتحان کن'));
} finally {
setSending(false);
}
}
return (
<Screen>
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
<div className="flex gap-1 rounded-2xl bg-soft p-1">
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارشِ خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
<button
key={id}
type="button"
onClick={() => setMode(id)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-xl py-2.5 text-xs font-bold ${mode === id ? 'bg-white text-indigo shadow-sm' : 'text-muted'}`}
>
<Icon size={14} /> {label}
</button>
))}
</div>
{mode === 'ticket' && (
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCategory(c.id)}
className={`rounded-full border-2 px-3 py-1.5 text-xs font-bold ${category === c.id ? 'border-indigo bg-indigo text-white' : 'border-line bg-white text-body'}`}
>
{c.label}
</button>
))}
</div>
)}
<input
value={subject}
onChange={(e) => setSubject(e.target.value.slice(0, 120))}
placeholder={mode === 'bug' ? 'چی خراب شد؟ (خلاصه)' : 'موضوع'}
className="w-full rounded-2xl border-2 border-line bg-white px-4 py-3.5 text-sm outline-none focus:border-indigo"
/>
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, 2000))}
rows={5}
placeholder={mode === 'bug' ? 'چه کار کردی و چه اتفاقی افتاد؟' : 'پیامت رو بنویس…'}
className="w-full resize-none rounded-2xl border-2 border-line bg-white px-4 py-3.5 text-sm leading-7 outline-none focus:border-indigo"
/>
{error && <InlineError>{error}</InlineError>}
<PrimaryButton color="#4C5FD5" loading={sending} disabled={!subject.trim() || !text.trim()} onClick={() => void submit()}>
<Send size={16} className="-scale-x-100" /> ارسال
</PrimaryButton>
{ticketIds.length > 0 && (
<section>
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیام‌های قبلیِ تو</h2>
<div className="flex flex-col gap-2">
{ticketIds.map((id) => <TicketCard key={id} ticketId={id} />)}
</div>
</section>
)}
</ScreenBody>
</Screen>
);
}
function TicketCard({ ticketId }: { ticketId: string }) {
const toast = useToast();
const ticket = useAsync(() => api.support.ticket(ticketId), [ticketId]);
const [open, setOpen] = useState(false);
const [reply, setReply] = useState('');
const [sending, setSending] = useState(false);
if (ticket.error || !ticket.data) {
return ticket.loading ? <div className="h-14 animate-pulse rounded-2xl bg-soft" /> : null;
}
const t: Ticket = ticket.data;
const staffReplies = t.messages.filter((m) => m.sender !== 'user').length;
async function send() {
if (!reply.trim()) return;
setSending(true);
try {
await api.support.reply(ticketId, reply.trim());
setReply('');
ticket.reload();
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
setSending(false);
}
}
return (
<div className="rounded-2xl border border-line bg-white">
<button type="button" onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-3 p-3.5 text-right">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink">{t.subject}</span>
<span className="text-[11px] text-muted">
{STATUS_LABEL[t.status] ?? t.status}
{staffReplies > 0 && ` · ${toFa(staffReplies)} پاسخ`}
</span>
</span>
</button>
{open && (
<div className="flex flex-col gap-2 border-t border-line p-3.5">
{t.messages.map((m) => (
<div
key={m.id}
className={`whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-xs leading-6 ${m.sender === 'user' ? 'mr-8 bg-soft text-body' : 'ml-8 bg-[#EEF1FF] text-ink'}`}
>
{m.text}
</div>
))}
{t.status !== 'closed' && (
<div className="mt-1 flex gap-2">
<input
value={reply}
onChange={(e) => setReply(e.target.value.slice(0, 2000))}
placeholder="جواب بده…"
className="flex-1 rounded-full bg-soft px-4 py-2 text-xs outline-none"
/>
<button type="button" disabled={!reply.trim() || sending} onClick={() => void send()} className="rounded-full bg-indigo px-3 text-xs font-bold text-white disabled:opacity-40">
ارسال
</button>
</div>
)}
</div>
)}
</div>
);
}

212
src/screens/UnitScreen.tsx Normal file
View File

@@ -0,0 +1,212 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react';
import { api, type HomeNode } from '@/api';
import { useAppData } from '@/state/AppData';
import { findUnit, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
const NODE_SIZE = 76;
/** نقشه‌ی جزیره‌های یک فصل، مثلِ مسیرِ دولینگو. */
export function UnitScreen({ unitId }: { unitId: string }) {
const { home, homeError, refreshHome, companion } = useAppData();
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId);
if (!found) {
return (
<Screen>
<ScreenHeader title="…" color="#91ADC6" back />
{homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : home ? <EmptyState text="این فصل پیدا نشد" /> : <LoadingState />}
</Screen>
);
}
const { unit, unitIndex } = found;
const color = unitColor(unitIndex);
const progress = unitProgress(unit);
const points = zigzagPositions(unit.nodes.length, 125, 90);
const height = points.length ? points[points.length - 1].y + 150 : 400;
return (
<Screen className="bg-[#f7f8ff]">
<ScreenHeader
title={unit.title}
subtitle={unit.blurb || `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
color={color}
back
mascot={{ char: companion, mood: progress.percent >= 50 ? 'happy' : 'idle', size: 72 }}
>
<div className="mt-4">
<div className="mb-1.5 flex justify-between text-[10px] text-white/75">
<span>پیشرفتِ تو</span>
<span>{toFa(progress.percent)}٪</span>
</div>
<ProgressBar percent={progress.percent} color="#fff" track="rgba(0,0,0,.1)" height={10} />
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto">
{unit.nodes.length === 0 ? (
<EmptyState text="این فصل هنوز جزیره‌ای نداره" />
) : (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath points={points} height={height} colorOf={(i) => (unit.nodes[i].status === 'completed' ? color : null)} />
{unit.nodes.map((node, i) => {
const locked = node.status === 'locked';
const done = node.status === 'completed';
const current = !locked && !done;
const Icon = iconFor(node.icon, i);
return (
<motion.div
key={node.id}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
{current && (
<>
<div
className="absolute -top-14 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-2xl border-2 bg-white px-3 py-1.5 text-[11px] font-black shadow-lg"
style={{ color, borderColor: color }}
>
{node.title}
<span className="absolute -bottom-2 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 border-b-2 border-r-2 bg-white" style={{ borderColor: color }} />
</div>
<motion.div
animate={{ scale: [1, 1.18, 1], opacity: [0.25, 0.05, 0.25] }}
transition={{ duration: 1.8, repeat: Infinity }}
className="absolute inset-[-13px] rounded-full"
style={{ backgroundColor: color }}
/>
</>
)}
<button
type="button"
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمام‌شده)' : ''}`}
onClick={() => setOpenNode(node)}
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
style={{
width: NODE_SIZE,
height: NODE_SIZE,
backgroundColor: locked ? '#e5e4ec' : done ? color : '#fff',
border: current ? `4px solid ${color}` : 'none',
boxShadow: locked ? '0 6px 0 #cccbd6' : done ? `0 7px 0 ${color}99` : `0 7px 0 ${color}55, 0 0 0 5px white`,
}}
>
{locked ? (
<Lock size={24} strokeWidth={2.5} className="text-[#aaa9b8]" />
) : done ? (
<Check size={31} color="white" strokeWidth={3.5} />
) : (
<Icon size={28} style={{ color }} strokeWidth={2.5} />
)}
</button>
{!current && (
<div className="absolute left-1/2 top-full mt-2.5 w-28 -translate-x-1/2 truncate rounded-lg bg-[#f7f8ff]/90 text-center text-[10px] font-bold text-muted">
{node.title}
</div>
)}
</motion.div>
);
})}
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 70 }}>
<span className="mb-1 text-[10px] font-bold text-[#aaa9bd]">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
<Sparkles size={20} style={{ color }} />
</span>
</div>
</div>
)}
</div>
<NodeSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
</Screen>
);
}
/** برگه‌ی جزیره: درس‌هایش، و دکمه‌ی شروعِ درسِ بعدی. */
function NodeSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
const nav = useNav();
const lessons = useAsync(() => (node ? api.nodeLessons(node.id) : Promise.resolve(null)), [node?.id]);
const next = node ? nextLessonOf(node) : null;
const locked = node?.status === 'locked';
const open = (lessonId: number) => {
if (!node) return;
onClose();
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
};
// فقط درس‌هایی که سوال دارند در lessonIds هستند؛ بقیه هنوز آزمون ندارند.
const playable = new Set(node?.lessonIds ?? []);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
const doneCount = list.filter((l) => l.done).length;
return (
<Sheet open={node !== null} onClose={onClose} title={node?.title}>
{locked ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<Lock size={24} className="text-muted" />
</span>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.</p>
</div>
) : (
<>
{lessons.loading ? (
<div className="py-6 text-center text-sm text-muted">دارم درس‌ها رو میارم…</div>
) : lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} />
) : (
<>
<p className="mb-3 text-xs text-muted">
{toFa(doneCount)} از {toFa(list.length)} درس تموم شده
</p>
<ul className="mb-5 flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{list.map((lesson) => {
const isNext = Number(lesson.id) === next;
return (
<li key={lesson.id}>
<button
type="button"
onClick={() => open(Number(lesson.id))}
className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-2.5 text-right text-sm ${
isNext ? 'border-2 bg-white font-bold' : 'border-line bg-white'
}`}
style={isNext ? { borderColor: color } : undefined}
>
{lesson.done ? (
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
) : isNext ? (
<Play size={16} className="flex-shrink-0" style={{ color }} fill={color} />
) : (
<Circle size={18} className="flex-shrink-0 text-faint" />
)}
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
</button>
</li>
);
})}
</ul>
</>
)}
{next != null && (
<PrimaryButton color={color} onClick={() => open(next)}>
<Play size={18} fill="white" /> {node?.status === 'completed' ? 'مرور دوباره' : 'شروعِ درسِ بعدی'}
</PrimaryButton>
)}
</>
)}
</Sheet>
);
}