no-hearts screen: live countdown, filling heart, auto-continue; refunded/canceled invoices
- The wait screen counts down to the next heart second by second, fills the first heart as time passes, shows when all five are back, and asks the server when the time comes; 'continue' reloads the quiz. Packs and 'ask Pi meanwhile' (asking costs no hearts) are offered as ways on. - The server now also refuses to finish a lesson/island without hearts and answers (403 no_hearts); the quiz shows the same screen then. - Invoices know the two admin-set statuses, canceled and refunded. - Account deletion waits for all six digits of the SMS code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
213
src/components/NoHeartsScreen.tsx
Normal file
213
src/components/NoHeartsScreen.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user