forked from Tadrisino/MathApp_frontend
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:
291
src/screens/AskScreen.tsx
Normal file
291
src/screens/AskScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/screens/CharactersScreen.tsx
Normal file
105
src/screens/CharactersScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
src/screens/FamilyScreen.tsx
Normal file
168
src/screens/FamilyScreen.tsx
Normal 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
180
src/screens/HomeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/screens/InviteScreen.tsx
Normal file
85
src/screens/InviteScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/screens/LeaderboardScreen.tsx
Normal file
87
src/screens/LeaderboardScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/screens/LessonScreen.tsx
Normal file
102
src/screens/LessonScreen.tsx
Normal 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
209
src/screens/LoginScreen.tsx
Normal 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
96
src/screens/MapScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/screens/NotificationsScreen.tsx
Normal file
99
src/screens/NotificationsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/screens/OnboardingScreen.tsx
Normal file
91
src/screens/OnboardingScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
src/screens/ProfileScreen.tsx
Normal file
283
src/screens/ProfileScreen.tsx
Normal 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
466
src/screens/QuizScreen.tsx
Normal 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
128
src/screens/SetupScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/screens/SplashScreen.tsx
Normal file
27
src/screens/SplashScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/screens/StreakScreen.tsx
Normal file
92
src/screens/StreakScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
src/screens/SubscriptionScreen.tsx
Normal file
192
src/screens/SubscriptionScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/screens/SupportScreen.tsx
Normal file
197
src/screens/SupportScreen.tsx
Normal 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
212
src/screens/UnitScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user