- http: a request whose 401 arrived after another request's refresh had finished refreshed again; it now resends with the new token. A refresh finishing after logout no longer restores tokens. - logout sends the refresh token current at send time (after a 401 -> refresh it had sent the rotated-out one and left the new session alive on the server for 30 days). - Sign-out/sign-in in another browser tab is followed (storage event). - Navigator removes its history entries when the main app unmounts (Back did nothing after logout). - Login: +98 / 0098 / 9xxxxxxxxx phone input (autofill, typing, paste) normalised; incomplete OTP is not sent (each wrong guess burns one of five). - Quiz resumes at the same question after hearts refill instead of restarting; the no-hearts screen re-checks while due (phone clock ahead of the server left it stuck at zero). - Payment return: a pending order is re-checked for about a minute instead of shown as failed; the pay button is reset when the page comes back from bfcache. - Tutor: retry keeps the quiz question id; a network error no longer forgets the conversation; daily quota detected by code ai_daily_quota. - Root error boundary and vite:preloadError reload instead of a white screen; deploy.sh keeps hashed assets of earlier builds for 30 days so open apps can still load their chunks. - Sheet: labelled dialog, focus moves in and back, Escape closes only the top sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
4.1 KiB
TypeScript
98 lines
4.1 KiB
TypeScript
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
|
import { AnimatePresence, motion, useDragControls } from 'motion/react';
|
|
import { X } from 'lucide-react';
|
|
import { useBackStep } from '@/nav/Navigator';
|
|
|
|
/**
|
|
* برگهای که از پایین بالا میآید؛ با زدنِ پسزمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته میشود.
|
|
* کشیدن فقط از بالای برگه شروع میشود تا با اسکرولِ محتوای بلندِ برگه قاطی نشود.
|
|
*/
|
|
export function Sheet({
|
|
open,
|
|
onClose,
|
|
title,
|
|
children,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
const drag = useDragControls();
|
|
const titleId = useId();
|
|
const panel = useRef<HTMLDivElement>(null);
|
|
// فوکوس با باز شدن به برگه میرود (صفحهخوان و صفحهکلید از همانجا ادامه میدهند) و با بسته شدن به دکمهای
|
|
// که بازش کرد برمیگردد. ورودیای که خودش autoFocus دارد فوکوسش را نگه میدارد.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
const t = setTimeout(() => {
|
|
if (!panel.current?.contains(document.activeElement)) panel.current?.focus({ preventScroll: true });
|
|
}, 0);
|
|
return () => {
|
|
clearTimeout(t);
|
|
if (opener?.isConnected) opener.focus({ preventScroll: true });
|
|
};
|
|
}, [open]);
|
|
// دکمهی برگشتِ گوشی اول برگه را میبندد، نه صفحهی زیرش را.
|
|
useBackStep(open, onClose);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<div className="absolute inset-0 z-50 flex flex-col justify-end">
|
|
<motion.div
|
|
className="absolute inset-0 bg-[#14142a]/55"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
/>
|
|
<motion.div
|
|
ref={panel}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={title ? titleId : undefined}
|
|
// Escape فقط برگهی رویی را میبندد (فوکوس داخلِ همان است)؛ قبلاً هر برگهی باز روی window گوش میداد
|
|
// و یک Escape برگهی زیرین را هم میبست.
|
|
tabIndex={-1}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}
|
|
}}
|
|
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3 outline-none"
|
|
style={{ paddingBottom: 'calc(2rem + env(safe-area-inset-bottom))' }}
|
|
initial={{ y: '100%' }}
|
|
animate={{ y: 0 }}
|
|
exit={{ y: '100%' }}
|
|
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
|
|
drag="y"
|
|
dragControls={drag}
|
|
dragListener={false}
|
|
dragConstraints={{ top: 0, bottom: 0 }}
|
|
dragElastic={{ top: 0, bottom: 0.9 }}
|
|
onDragEnd={(_, info) => {
|
|
if (info.offset.y > 90 || info.velocity.y > 600) onClose();
|
|
}}
|
|
>
|
|
<div className="-mx-5 -mt-3 cursor-grab touch-none px-5 pb-3 pt-3" onPointerDown={(e) => drag.start(e)}>
|
|
<div className="mx-auto h-1.5 w-10 rounded-full bg-line" />
|
|
</div>
|
|
{title && (
|
|
<div className="mb-4 flex touch-none items-center justify-between gap-3" onPointerDown={(e) => drag.start(e)}>
|
|
<h2 id={titleId} className="text-base font-black text-ink">{title}</h2>
|
|
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{children}
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|