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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user