Smooth screen transitions: new screen waits hidden until its data arrives, no half-built flash
- Navigator stage: the next screen mounts hidden under the current one and is revealed only when its first data has arrived (ScreenGate held by useAsync/useGateHold), at most 450 ms; a thin top bar shows after 120 ms of waiting. Push slides from the left, back from the right, tabs only fade. - Session cache (stale-while-revalidate) for screen GETs: revisited screens draw instantly. - Home map and stats are fetched during the splash, so the first screen arrives complete. - Entrance pop-ins (mascot, islands, lesson cards) run on first view only, not on every revisit. - Shop status card shows placeholders instead of a wrong «نداری» while loading. - Re-tapping the open tab scrolls to top instead of reloading; sheets close with a swipe down. - Quiz: «۱ از ۴» instead of «۱/۴» (read as a fraction), varied praise, «N تا پشتِ هم درست», growth-mindset copy after a weak round, zero-reward tiles hidden. - Streak: a concrete next step when the streak is 0 or today is still empty. Checked in headless Chrome against the local backend (frame captures at 150 ms and 700 ms latency, rapid tab tapping, full quiz run). Not checked on a real phone or iOS Safari. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { Diagram } from '@/diagram';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
|
||||
const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||||
|
||||
@@ -23,9 +24,16 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
const [busy, setBusy] = useState(false);
|
||||
// «X سوالِ رایگانِ امروز + Y سوالِ خریدهشده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
|
||||
const [quotaDone, setQuotaDone] = useState(false);
|
||||
useEffect(() => {
|
||||
api.ai.quota().then((q) => setQuota({ free: q.remainingQuota, credits: q.credits })).catch(() => {});
|
||||
api.ai
|
||||
.quota()
|
||||
.then((q) => setQuota({ free: q.remainingQuota, credits: q.credits }))
|
||||
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
|
||||
.finally(() => setQuotaDone(true));
|
||||
}, []);
|
||||
// صفحه وقتی میآید که تاریخچه و سهمیه رسیده باشند؛ وگرنه اول خوشآمد و بعد پیامهای قبلی رویش میپرید.
|
||||
useGateHold(!chat.historyLoaded || !quotaDone);
|
||||
const listEnd = useRef<HTMLDivElement>(null);
|
||||
const autoSent = useRef(false);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { toFa } from '@/lib/format';
|
||||
export function CharactersScreen() {
|
||||
const { summary, setCompanion } = useAppData();
|
||||
const toast = useToast();
|
||||
const list = useAsync(() => api.characters.list(), []);
|
||||
const list = useAsync(() => api.characters.list(), [], { key: 'characters' });
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
async function select(c: Character) {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function inviteLink(code: string): string {
|
||||
|
||||
export function InviteScreen() {
|
||||
const toast = useToast();
|
||||
const me = useAsync(() => api.affiliate.me(), []);
|
||||
const me = useAsync(() => api.affiliate.me(), [], { key: 'affiliate' });
|
||||
|
||||
async function share(code: string) {
|
||||
const url = inviteLink(code);
|
||||
|
||||
@@ -27,7 +27,7 @@ const dateOf = (invoice: Invoice) => formatJalaliDate(invoice.paidAt ?? invoice.
|
||||
|
||||
export function InvoicesScreen() {
|
||||
const nav = useNav();
|
||||
const list = useAsync(() => api.subscription.invoices(), []);
|
||||
const list = useAsync(() => api.subscription.invoices(), [], { key: 'invoices' });
|
||||
const [open, setOpen] = useState<Invoice | null>(null);
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ const MEDAL: Record<number, { bg: string; fg: string; h: number }> = {
|
||||
export function LeaderboardScreen() {
|
||||
const { profile } = useAppData();
|
||||
const [period, setPeriod] = useState<LeaderboardPeriod>('weekly');
|
||||
const board = useAsync(() => api.leaderboard(period), [period]);
|
||||
const board = useAsync(() => api.leaderboard(period), [period], { key: `leaderboard:${period}` });
|
||||
const entries = board.data ?? [];
|
||||
const podium = [entries[1], entries[0], entries[2]].filter(Boolean);
|
||||
const isMe = (userId: string) => userId === profile.userId;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Diagram } from '@/diagram';
|
||||
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 intro = useAsync(() => api.lesson.intro(lessonId), [lessonId], { key: `lesson-intro:${lessonId}` });
|
||||
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||
const data = intro.data;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { useFirstVisit } from '@/lib/firstVisit';
|
||||
|
||||
/**
|
||||
* تبِ «درسها»: فصل ← جزیره ← درس. فصلها اول بستهاند و بالای صفحه یک کارتِ «ادامه بده» هست.
|
||||
@@ -117,9 +118,10 @@ function UnitCard({
|
||||
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
||||
const nav = useNav();
|
||||
const premium = unit.requiresSubscription;
|
||||
const intro = useFirstVisit(`unit-card:${unit.id}`);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
initial={intro ? { opacity: 0, y: 12 } : { opacity: 1, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index, 8) * 0.04 }}
|
||||
className="overflow-hidden rounded-2xl bg-white"
|
||||
|
||||
@@ -23,7 +23,7 @@ export function NotificationsScreen() {
|
||||
const r = await api.notifications.list();
|
||||
setUnreadCount(r.unreadCount);
|
||||
return r.items;
|
||||
}, []);
|
||||
}, [], { key: 'notifications' });
|
||||
const items = list.data ?? [];
|
||||
const unread = items.filter((n) => !n.isRead).length;
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
* موعد روزها را روی هم میگذارد، پس نوار در ۱۰۰٪ بسته میشود. زدنِ کارت فروشگاه را باز میکند.
|
||||
*/
|
||||
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
const status = useAsync(() => api.subscription.status(), []);
|
||||
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
|
||||
const s = status.data;
|
||||
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
|
||||
const days = s.active ? daysLeft(s.expiresAt) : 0;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { sounds } from '@/lib/sound';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
|
||||
type Phase =
|
||||
| { kind: 'loading' }
|
||||
@@ -62,6 +63,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
const [confirmExit, setConfirmExit] = useState(false);
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
|
||||
const [combo, setCombo] = useState(0);
|
||||
useGateHold(phase.kind === 'loading');
|
||||
|
||||
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمیشود.
|
||||
const answerKeys = useRef(new Map<string, string>());
|
||||
@@ -90,6 +94,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
setSelected(null);
|
||||
setAnswer(null);
|
||||
setCorrectCount(0);
|
||||
setCombo(0);
|
||||
answerKeys.current.clear();
|
||||
completeKey.current = newIdempotencyKey();
|
||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||
@@ -127,8 +132,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
|
||||
if (result.correct) {
|
||||
setCorrectCount((c) => c + 1);
|
||||
setCombo((c) => c + 1);
|
||||
sounds.correct();
|
||||
} else {
|
||||
setCombo(0);
|
||||
sounds.wrong();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -252,10 +259,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
const bubble = !answer
|
||||
? selected === null ? 'جوابِ درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.'
|
||||
: answer.correct
|
||||
? 'آفرین! درست بود'
|
||||
? combo >= 2 ? `${toFa(combo)} تا پشتِ هم درست! داری عالی پیش میری` : PRAISE[index % PRAISE.length]
|
||||
: outOfHearts
|
||||
? 'قلبهات تموم شد؛ یه کم استراحت کنیم.'
|
||||
: 'اشکالی نداره، جوابِ درست رو ببین.';
|
||||
: NUDGE[index % NUDGE.length];
|
||||
const shortOptions = question.options.every((o) => o.value.length <= 14);
|
||||
|
||||
const optionClass = (id: string) => {
|
||||
@@ -275,8 +282,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
<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 className="flex-shrink-0 text-left text-xs font-bold text-muted">
|
||||
{/* «۱/۴» در اپِ ریاضی کسر خوانده میشد. */}
|
||||
{toFa(index + 1)} از {toFa(questions.length)}
|
||||
</span>
|
||||
</div>
|
||||
<HeartsRow hearts={hearts} />
|
||||
@@ -451,8 +459,15 @@ function Result({
|
||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی</p>}
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||
{/* نمرهی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهشهای ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
|
||||
{result.stars <= 1 && (
|
||||
<p className="mt-2 text-xs leading-6 text-body">
|
||||
اشتباهها همون جاهاییان که مغزت داره یاد میگیره. هر وقت خواستی از «درسها» دوباره مرورش کن.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full gap-3">
|
||||
{/* «+۰ امتیاز، +۰ سکه» بعد از یک دورِ بد فقط نمک روی زخم بود؛ پاداشِ صفر نشان داده نمیشود. */}
|
||||
{(result.xpEarned > 0 || result.coinsEarned > 0) && <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>
|
||||
@@ -463,7 +478,7 @@ function Result({
|
||||
<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>
|
||||
</div>}
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
||||
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
@@ -477,6 +492,13 @@ function Result({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* پیامِ بعد از جواب. یک جملهی ثابت بعد از چند بار دیگر خوانده نمیشود؛ چند تا به نوبت. برای جوابِ غلط
|
||||
* تلاش و راه ستایش میشود و قدمِ بعدی گفته میشود، نه اینکه «اشکالی نداره» تنها بماند.
|
||||
*/
|
||||
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
|
||||
const NUDGE = ['نزدیک بودی! جوابِ درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد میگیریم.', 'این یکی سخت بود؛ جوابِ درست سبز شده.'];
|
||||
|
||||
const REASONS: { id: ReportReason; label: string }[] = [
|
||||
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
|
||||
{ id: 'unclear', label: 'صورتِ سوال گنگه' },
|
||||
|
||||
@@ -37,7 +37,7 @@ const percent = (part: number, whole: number) => (whole ? Math.round((part / who
|
||||
|
||||
export function ReportScreen() {
|
||||
const { profile } = useAppData();
|
||||
const report = useAsync(() => api.weeklyReport(), []);
|
||||
const report = useAsync(() => api.weeklyReport(), [], { key: 'weekly-report' });
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
|
||||
@@ -29,7 +29,7 @@ function recentWeeks(today = new Date()): Date[][] {
|
||||
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 calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' });
|
||||
const active = new Set(calendar.data?.activeDays ?? []);
|
||||
const today = isoDay(new Date());
|
||||
const weeks = recentWeeks();
|
||||
@@ -57,7 +57,7 @@ export function StreakScreen() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StreakGoal current={calendar.data.currentStreak} />
|
||||
<StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} />
|
||||
|
||||
<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">
|
||||
@@ -96,11 +96,19 @@ export function StreakScreen() {
|
||||
/** نقطهعطفهای زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعیای پشتش نیست (پاداش تصمیمِ محصولی است). */
|
||||
const MILESTONES = [3, 7, 14, 30, 60, 100];
|
||||
|
||||
function StreakGoal({ current }: { current: number }) {
|
||||
function StreakGoal({ current, doneToday }: { current: number; doneToday: boolean }) {
|
||||
const next = MILESTONES.find((m) => m > current);
|
||||
const reached = MILESTONES.filter((m) => m <= current).pop();
|
||||
// زنجیرهی صفر: «۳ روز دیگه میشه زنجیرهی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته میشود.
|
||||
// زنجیرهی روشن و امروزِ خالی: یادآوریِ اینکه امروز چیزی مانده تا زنجیره نسوزد.
|
||||
const today = current === 0
|
||||
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیرهت روشن بشه.'
|
||||
: !doneToday
|
||||
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیرهی ${toFa(current)} روزهت نسوزه.`
|
||||
: null;
|
||||
return (
|
||||
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
|
||||
{today && <p className="mb-1 text-xs font-bold leading-6 text-[#B4501C]">{today}</p>}
|
||||
<p className="text-sm font-black leading-7 text-ink">
|
||||
{reached ? <><Flame size={16} fill="#FF7A3D" className="mb-0.5 ml-1 inline text-orange" />{toFa(current)} روزهای! </> : null}
|
||||
{next ? `${toFa(next - current)} روز دیگه میشه زنجیرهی ${toFa(next)} روزه.` : 'از همهی نقطهعطفها رد شدی، افسانهای!'}
|
||||
|
||||
@@ -26,9 +26,9 @@ type View = 'subscription' | 'pack';
|
||||
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
||||
*/
|
||||
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||
const plans = useAsync(() => api.subscription.plans(), []);
|
||||
const status = useAsync(() => api.subscription.status(), []);
|
||||
const quota = useAsync(() => api.ai.quota(), []);
|
||||
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
|
||||
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
|
||||
const quota = useAsync(() => api.ai.quota(), [], { key: 'ai-quota' });
|
||||
const [view, setView] = useState<View | null>(initialView ?? null);
|
||||
const [buying, setBuying] = useState<Plan | null>(null);
|
||||
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
|
||||
@@ -53,7 +53,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-5">
|
||||
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
|
||||
<StatusCard status={status.data ?? null} statusLoading={status.loading && !status.data} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} quotaLoading={quota.loading && !quota.data} />
|
||||
|
||||
{view === null ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -132,8 +132,11 @@ function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNod
|
||||
}
|
||||
|
||||
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
|
||||
function StatusCard({ status, credits, heartsUntil, quota }: {
|
||||
status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null;
|
||||
/** تا وضعیت نرسیده جای خالیِ هماندازه نشان داده میشود؛ قبلاً «نداری» میآمد و برای مشترک غلط بود. */
|
||||
const Placeholder = ({ className }: { className: string }) => <span className={`inline-block animate-pulse rounded-lg bg-soft align-middle ${className}`} />;
|
||||
|
||||
function StatusCard({ status, statusLoading, credits, heartsUntil, quota, quotaLoading }: {
|
||||
status: SubscriptionStatus | null; statusLoading: boolean; credits: number; heartsUntil: string | null; quota: TutorQuota | null; quotaLoading: boolean;
|
||||
}) {
|
||||
const days = status?.active ? daysLeft(status.expiresAt) : null;
|
||||
return (
|
||||
@@ -143,9 +146,10 @@ function StatusCard({ status, credits, heartsUntil, quota }: {
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
||||
<div className="text-base font-black text-ink">
|
||||
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
||||
{statusLoading ? <Placeholder className="h-5 w-20" /> : status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
||||
</div>
|
||||
</div>
|
||||
{quotaLoading && <Placeholder className="h-10 w-14" />}
|
||||
{quota && (
|
||||
<div className="text-left text-xs leading-6 text-muted">
|
||||
سوالِ امروز<br />
|
||||
@@ -154,7 +158,7 @@ function StatusCard({ status, credits, heartsUntil, quota }: {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
|
||||
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {toFa(credits)} سوال</span>
|
||||
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {statusLoading && quotaLoading ? <Placeholder className="h-3.5 w-10" /> : <>{toFa(credits)} سوال</>}</span>
|
||||
{(status?.active || heartsUntil) && (
|
||||
<span className="flex items-center gap-1.5 text-coral">
|
||||
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SupportScreen() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// فهرست از سرور میآید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده میشود.
|
||||
const tickets = useAsync(() => api.support.tickets(), []);
|
||||
const tickets = useAsync(() => api.support.tickets(), [], { key: 'support-tickets' });
|
||||
|
||||
async function submit() {
|
||||
if (!subject.trim() || !text.trim()) return;
|
||||
|
||||
Reference in New Issue
Block a user