Files
MathApp_frontend/src/screens/CharactersScreen.tsx
AliReza 0957ef476b lighter first load, math reads left-to-right in quizzes, tickets from the server, readable separators
- Every screen but home is its own chunk and preloads when the browser is
  idle; React and motion get their own long-cached chunks. The app entry
  goes from 505 kB (160 kB gzip) to 85 kB (27 kB gzip).
- Quiz prompts, options and lesson examples go through MathText, so
  '10 + 3 - 7 - 2' reads from the left; an unpaired bracket at the edge of
  a math run stays with the Persian sentence.
- Support lists tickets from GET /support/tickets instead of this
  browser's localStorage; a ticket's messages load when it is opened.
- '·' next to a Persian zero read as another digit ('فصلِ ۱ · ۰' looked
  like ۱۰۰); a thin bar separates them now. Notification bodies show
  Persian digits.
- A root or vertex the model already marked with coordinates is no longer
  labelled a second time on top of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:18:49 +03:30

106 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Sep } 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)}<Sep />با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن</>}
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>
);
}