214 lines
9.6 KiB
TypeScript
214 lines
9.6 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { motion } from 'motion/react';
|
||
import { Brain, Heart, HeartCrack, Infinity as InfinityIcon, ShoppingBag, X } from 'lucide-react';
|
||
import { api } from '@/api';
|
||
import { useAppData } from '@/state/AppData';
|
||
import { useNav } from '@/nav/Navigator';
|
||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||
import { PrimaryButton, Screen } from '@/components/ui';
|
||
import { toFa } from '@/lib/format';
|
||
|
||
/**
|
||
* «قلبهات تموم شد»: با قلبِ صفر سرور هیچ جوابی نمیپذیرد (۴۰۳ با code=no_hearts) و پایانِ درس یا
|
||
* جزیره را هم بیجواب نمیپذیرد. این صفحه شمارشِ معکوسِ قلبِ بعدی را زنده نشان میدهد و وقتی رسید،
|
||
* خودش از سرور میپرسد و دکمهی «ادامه بده» را میآورد.
|
||
*/
|
||
|
||
/** همان HEART_REFILL_INTERVALِ سرور (progress/models.py)؛ فقط برای پرشدنِ نوار و قلبِ اول. */
|
||
const REFILL_MS = 5 * 60 * 60 * 1000;
|
||
const MAX_HEARTS = 5;
|
||
|
||
function useNow(): number {
|
||
const [now, setNow] = useState(() => Date.now());
|
||
useEffect(() => {
|
||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||
return () => window.clearInterval(id);
|
||
}, []);
|
||
return now;
|
||
}
|
||
|
||
/** «۴:۰۷:۳۲» */
|
||
function clock(ms: number): string {
|
||
const s = Math.max(0, Math.ceil(ms / 1000));
|
||
const h = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
return toFa(`${h}:${pad(m)}:${pad(s % 60)}`);
|
||
}
|
||
|
||
/** «ساعتِ ۱۸:۳۰ امروز» / «… فردا» / «… شنبه» */
|
||
function whenLabel(iso: string, now: number): string {
|
||
const at = new Date(iso);
|
||
const time = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit', hour12: false }).format(at);
|
||
const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||
const days = Math.round((dayOf(at) - dayOf(new Date(now))) / 86_400_000);
|
||
const day = days <= 0 ? 'امروز' : days === 1 ? 'فردا' : new Intl.DateTimeFormat('fa-IR', { weekday: 'long' }).format(at);
|
||
return `ساعتِ ${time} ${day}`;
|
||
}
|
||
|
||
export function NoHeartsScreen({
|
||
nextRefillAt,
|
||
fullRefillAt,
|
||
onRefilled,
|
||
}: {
|
||
nextRefillAt: string | null;
|
||
fullRefillAt: string | null;
|
||
/** قلب برگشت و کاربر «ادامه بده» زد. */
|
||
onRefilled: () => void;
|
||
}) {
|
||
const nav = useNav();
|
||
const { companion } = useAppData();
|
||
const now = useNow();
|
||
const [next, setNext] = useState(nextRefillAt);
|
||
const [full, setFull] = useState(fullRefillAt);
|
||
const [back, setBack] = useState(false);
|
||
|
||
const left = next ? Date.parse(next) - now : null;
|
||
const progress = left === null ? 0 : Math.min(1, Math.max(0, 1 - left / REFILL_MS));
|
||
const due = left !== null && left <= 0;
|
||
|
||
// زمانِ قلبِ بعدی رسید: از سرور میپرسیم (ساعتِ گوشی ممکن است جلو باشد).
|
||
useEffect(() => {
|
||
if (!due || back) return;
|
||
let alive = true;
|
||
const check = () =>
|
||
api.hearts().then((h) => {
|
||
if (!alive) return;
|
||
if (h.isUnlimited || h.currentHearts > 0) setBack(true);
|
||
else {
|
||
setNext(h.nextRefillAt);
|
||
setFull(h.fullRefillAt);
|
||
}
|
||
}).catch(() => undefined);
|
||
void check();
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [due, back]);
|
||
|
||
return (
|
||
<Screen className="bg-gradient-to-b from-[#FFF1EE] via-page to-page">
|
||
<div className="flex justify-start px-5 pt-safe">
|
||
<button type="button" aria-label="برگشت" onClick={nav.back} className="mt-5 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow-sm">
|
||
<X size={16} className="text-coral" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-1 flex-col items-center gap-5 overflow-y-auto px-6 pb-8 pt-2 text-center [&>*]:shrink-0">
|
||
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="relative">
|
||
<div className="rounded-full bg-white p-4 shadow-[0_8px_30px_rgba(237,105,86,0.18)]">
|
||
<SeyarMascot char={companion} mood={back ? 'excited' : 'depressed'} size={104} />
|
||
</div>
|
||
<motion.span
|
||
className="absolute -left-2 -top-1 flex h-11 w-11 items-center justify-center rounded-full bg-white shadow"
|
||
animate={back ? { scale: [1, 1.25, 1] } : { rotate: [0, -8, 8, 0] }}
|
||
transition={{ duration: back ? 0.6 : 2.4, repeat: back ? 0 : Infinity }}
|
||
>
|
||
{back ? <Heart size={24} className="text-coral" fill="#ED6956" /> : <HeartCrack size={24} className="text-coral" />}
|
||
</motion.span>
|
||
</motion.div>
|
||
|
||
<div>
|
||
<h1 className="text-2xl font-black text-ink">{back ? 'یه قلب برگشت!' : 'قلبهات تموم شد'}</h1>
|
||
<p className="mt-1.5 text-sm leading-7 text-muted">
|
||
{back ? 'بریم ادامه بدیم؛ این بار با دقتِ بیشتر' : 'یه کم استراحت کن؛ مغزت هم استراحت لازم داره.'}
|
||
</p>
|
||
</div>
|
||
|
||
<HeartSlots progress={back ? 1 : progress} />
|
||
|
||
{back ? (
|
||
<div className="w-full">
|
||
<PrimaryButton onClick={onRefilled}>ادامه بده</PrimaryButton>
|
||
</div>
|
||
) : (
|
||
<div className="w-full rounded-3xl border border-line bg-white p-5 shadow-sm">
|
||
<div className="text-xs font-bold text-muted">قلبِ بعدی</div>
|
||
{left !== null ? (
|
||
<>
|
||
<div dir="ltr" className="mt-1 text-4xl font-black tracking-wide text-coral tabular-nums">{clock(left)}</div>
|
||
<div className="mt-4 h-2.5 overflow-hidden rounded-full bg-soft">
|
||
<motion.div
|
||
className="h-full rounded-full bg-gradient-to-l from-coral to-[#FF9A7A]"
|
||
animate={{ width: `${Math.max(progress * 100, 3)}%` }}
|
||
transition={{ ease: 'linear', duration: 1 }}
|
||
/>
|
||
</div>
|
||
{full && (
|
||
<p className="mt-3 text-xs leading-6 text-muted">
|
||
همهی {toFa(MAX_HEARTS)} قلب: <b className="text-body">{whenLabel(full, now)}</b>
|
||
</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p className="mt-2 text-sm text-body">کمی صبر کن تا قلبهات دوباره پر بشن.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!back && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => nav.switchTab('subscription')}
|
||
className="relative w-full overflow-hidden rounded-3xl bg-gradient-to-l from-coral to-orange p-5 text-right text-white shadow-lg shadow-coral/30 active:scale-[0.99]"
|
||
>
|
||
<span className="absolute -left-6 -top-6 h-24 w-24 rounded-full bg-white/10" />
|
||
<span className="flex items-center gap-2 text-base font-black">
|
||
<Heart size={18} fill="#fff" /> <InfinityIcon size={18} /> نمیخوای منتظر بمونی؟
|
||
</span>
|
||
<span className="mt-1 block text-xs leading-6 text-white/90">
|
||
هر بستهی سوال چند روز قلبِ نامحدود هدیه داره؛ هر چقدر بخوای تمرین کن.
|
||
</span>
|
||
<span className="mt-3 inline-flex items-center gap-1.5 rounded-2xl bg-white px-4 py-2 text-sm font-black text-coral">
|
||
<ShoppingBag size={15} /> دیدنِ بستهها
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => nav.switchTab('ask')}
|
||
className="flex w-full items-center gap-3 rounded-3xl border border-line bg-white p-4 text-right active:scale-[0.99]"
|
||
>
|
||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl bg-[#F5F1FF] text-violet">
|
||
<Brain size={20} />
|
||
</span>
|
||
<span className="flex-1">
|
||
<span className="block text-sm font-black text-ink">تا اون موقع از «پی» بپرس</span>
|
||
<span className="block text-xs leading-6 text-muted">جایی که گیر کردی رو با هم مرور کنیم؛ قلب خرج نمیکنه.</span>
|
||
</span>
|
||
</button>
|
||
|
||
<button type="button" onClick={nav.back} className="py-1 text-sm font-bold text-muted">
|
||
باشه، بعداً
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
/** پنج جای قلب؛ اولی همراهِ زمان از پایین پر میشود. */
|
||
function HeartSlots({ progress }: { progress: number }) {
|
||
return (
|
||
<div className="flex items-center gap-2.5" aria-label={`قلبِ بعدی ${toFa(Math.round(progress * 100))} درصد پر شده`}>
|
||
{Array.from({ length: MAX_HEARTS }, (_, i) => (
|
||
<span key={i} className="relative h-8 w-8">
|
||
<Heart size={32} className="absolute inset-0 text-[#F2DCD8]" fill="#FBEDEA" />
|
||
{i === 0 && (
|
||
<motion.span
|
||
className="absolute inset-0 overflow-hidden"
|
||
style={{ clipPath: `inset(${(1 - progress) * 100}% 0 0 0)` }}
|
||
animate={progress < 1 ? { opacity: [0.75, 1, 0.75] } : { scale: [1, 1.3, 1] }}
|
||
transition={{ duration: progress < 1 ? 1.6 : 0.5, repeat: progress < 1 ? Infinity : 0 }}
|
||
>
|
||
<Heart size={32} className="text-coral" fill="#ED6956" />
|
||
</motion.span>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|