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

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

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

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

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

View File

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