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