- 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>
353 lines
19 KiB
TypeScript
353 lines
19 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
||
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
|
||
import { daysLeft } from '@/lib/subscription';
|
||
import { newIdempotencyKey } from '@/api/http';
|
||
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
||
import { useAsync } from '@/lib/useAsync';
|
||
import { Sheet } from '@/components/Sheet';
|
||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||
import { friendlyError } from '@/lib/errors';
|
||
import { formatJalaliDate, formatToman, toFa } from '@/lib/format';
|
||
|
||
function durationLabel(days: number): string {
|
||
if (days === 30 || days === 31) return 'یکماهه';
|
||
if (days >= 360 && days <= 370) return 'یکساله';
|
||
if (days === 90) return 'سهماهه';
|
||
if (days === 180) return 'ششماهه';
|
||
return days ? `${toFa(days)} روزه` : '';
|
||
}
|
||
|
||
type View = 'subscription' | 'pack';
|
||
|
||
/**
|
||
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستیار)
|
||
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
|
||
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
||
*/
|
||
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||
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);
|
||
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
|
||
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
||
const bazaar = inBazaarApp();
|
||
|
||
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
||
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
||
const reload = () => { status.reload(); quota.reload(); };
|
||
const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
|
||
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
|
||
|
||
return (
|
||
<Screen>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<ScreenHeader
|
||
title={view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
|
||
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همهی فصلها، قلبِ نامحدود'}
|
||
color="#ED6956"
|
||
exact
|
||
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
|
||
/>
|
||
|
||
<div className="flex flex-col gap-4 px-4 py-5">
|
||
<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">
|
||
<ChoiceCard
|
||
icon={<Crown size={24} />}
|
||
tone="#FF7A3D"
|
||
title="اشتراکِ ماهانه"
|
||
text="همهی فصلها و جزیرهها باز، با قلبِ نامحدود"
|
||
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
|
||
onClick={() => setView('subscription')}
|
||
/>
|
||
<ChoiceCard
|
||
icon={<Sparkles size={24} />}
|
||
tone="#9B7BFF"
|
||
title="بستهی سوال"
|
||
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچوقت منقضی نمیشه"
|
||
onClick={() => setView('pack')}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{!initialView && (
|
||
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
|
||
<ChevronRight size={14} /> برگشت به انتخاب
|
||
</button>
|
||
)}
|
||
{plans.loading ? (
|
||
<LoadingState text="دارم فهرست رو میارم…" />
|
||
) : plans.error ? (
|
||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||
) : !list.length ? (
|
||
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
||
) : (
|
||
list.map((plan) => (
|
||
<PackCard
|
||
key={plan.id}
|
||
plan={plan}
|
||
monthlyPrice={monthly?.price}
|
||
// اشتراک در بازار هنوز SKU و تمدیدِ آزمودهشده ندارد؛ آنجا فقط بسته خریدنی است.
|
||
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
|
||
onBuy={() => setBuying(plan)}
|
||
/>
|
||
))
|
||
)}
|
||
{view === 'pack' && (
|
||
<p className="text-center text-xs leading-6 text-muted">
|
||
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانهته؛ بعدش از اعتبارِ بسته کم میشه.
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
||
{bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
|
||
return (
|
||
<button type="button" onClick={onClick} className="flex items-center gap-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]">
|
||
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-base font-black text-ink">{title}</span>
|
||
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
|
||
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
|
||
</span>
|
||
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
|
||
/** تا وضعیت نرسیده جای خالیِ هماندازه نشان داده میشود؛ قبلاً «نداری» میآمد و برای مشترک غلط بود. */
|
||
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 (
|
||
<div className="rounded-3xl border border-line bg-white p-4">
|
||
<div className="flex items-center gap-3">
|
||
<span className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span>
|
||
<div className="flex-1">
|
||
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
||
<div className="text-base font-black text-ink">
|
||
{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 />
|
||
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
|
||
</div>
|
||
)}
|
||
</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} /> اعتبار: {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)}</>}
|
||
</span>
|
||
)}
|
||
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) {
|
||
const months = Math.round(plan.durationDays / 30);
|
||
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
|
||
const featured = plan.popular;
|
||
const isPack = plan.kind === 'pack';
|
||
const tone = featured ? 'text-white/80' : 'text-muted';
|
||
return (
|
||
<div className="relative pt-3">
|
||
{plan.badge && (
|
||
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">{plan.badge}</span>
|
||
)}
|
||
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}>
|
||
<div className="mb-3 flex items-start justify-between gap-3">
|
||
<div>
|
||
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
|
||
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
||
</div>
|
||
<div className="text-left">
|
||
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
||
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
|
||
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزانتر</p>}
|
||
</div>
|
||
</div>
|
||
<ul className="mb-4 flex flex-col gap-2 text-sm">
|
||
{isPack && plan.heartsDays > 0 && (
|
||
<li className="flex items-center gap-2 font-bold">
|
||
<Heart size={14} fill={featured ? '#fff' : '#ED6956'} className={featured ? 'text-white' : 'text-coral'} />
|
||
هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
|
||
</li>
|
||
)}
|
||
{!isPack && plan.aiDailyQuota > 0 && (
|
||
<li className="flex items-center gap-2 font-bold"><Sparkles size={14} /> {toFa(plan.aiDailyQuota)} سوال در روز از دستیار</li>
|
||
)}
|
||
{plan.features.map((f) => (
|
||
<li key={f} className="flex items-center gap-2">
|
||
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
||
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button
|
||
type="button"
|
||
onClick={onBuy}
|
||
disabled={!available}
|
||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-50 ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
|
||
>
|
||
{available ? 'خرید' : 'بهزودی در بازار'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* خرید: سفارش ساخته میشود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت میشود،
|
||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
||
*/
|
||
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
|
||
const [code, setCode] = useState('');
|
||
const [showCode, setShowCode] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [done, setDone] = useState<'code' | 'bazaar' | null>(null);
|
||
|
||
/**
|
||
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
|
||
* کدِ تخفیف اینجا نیست: بازار قیمتِ ثابتِ محصول را میگیرد و سرور تخفیف را رد میکند.
|
||
*/
|
||
async function payWithBazaar(plan: Plan) {
|
||
const sku = plan.bazaarSku;
|
||
let key = bazaarPending.get(sku);
|
||
if (key) {
|
||
// کلیدِ مانده از خریدِ قبلی فقط وقتی به کار میآید که سفارشش هنوز در انتظار است؛ سفارشِ
|
||
// پرداختشده یا ناموفق با خریدِ تازه قاطی نشود.
|
||
const previous = await api.subscription.purchaseBazaar(plan.id, key).catch(() => null);
|
||
const status = previous ? await api.subscription.order(previous.orderId).then((o) => o.status).catch(() => null) : null;
|
||
if (status !== 'pending') key = null;
|
||
}
|
||
if (!key) {
|
||
key = newIdempotencyKey();
|
||
bazaarPending.set(sku, key);
|
||
}
|
||
const order = await api.subscription.purchaseBazaar(plan.id, key);
|
||
let token: string;
|
||
try {
|
||
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
|
||
} catch (err) {
|
||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
|
||
throw err;
|
||
}
|
||
const result = await api.subscription.verifyBazaar(order.orderId, token);
|
||
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
|
||
bazaarPending.clear(sku);
|
||
bazaarConsume(token);
|
||
return true;
|
||
}
|
||
|
||
async function pay() {
|
||
if (!plan || busy) return;
|
||
setBusy(true);
|
||
setError('');
|
||
if (bazaar) {
|
||
try {
|
||
if (await payWithBazaar(plan)) setDone('bazaar');
|
||
} catch (err) {
|
||
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
|
||
}
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
try {
|
||
const order = await api.subscription.purchase(plan.id);
|
||
if (code.trim()) {
|
||
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
|
||
if (redeemed.orderStatus === 'paid') {
|
||
setDone('code');
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
}
|
||
if (!order.paymentUrl) throw new Error('order without paymentUrl');
|
||
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
||
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
||
window.location.assign(order.paymentUrl);
|
||
} catch (err) {
|
||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
const close = () => {
|
||
if (done) onPaid();
|
||
else onClose();
|
||
setCode('');
|
||
setShowCode(false);
|
||
setError('');
|
||
setDone(null);
|
||
};
|
||
|
||
return (
|
||
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
||
{done ? (
|
||
<>
|
||
<p className="mb-5 text-sm leading-7 text-muted">
|
||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
||
</p>
|
||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||
</>
|
||
) : plan && (
|
||
<>
|
||
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3">
|
||
<span className="text-sm text-body">مبلغ</span>
|
||
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
||
</div>
|
||
{bazaar ? null : showCode ? (
|
||
<label className="mb-4 block">
|
||
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
||
<input
|
||
value={code}
|
||
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
|
||
dir="ltr"
|
||
autoCapitalize="characters"
|
||
placeholder="CODE"
|
||
className="w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-coral"
|
||
/>
|
||
<span className="mt-1.5 block text-[11px] text-muted">مبلغِ نهایی بعد از اعمالِ کد در درگاه نشون داده میشه.</span>
|
||
</label>
|
||
) : (
|
||
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
|
||
<Ticket size={15} /> کدِ تخفیف دارم
|
||
</button>
|
||
)}
|
||
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
|
||
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
|
||
</>
|
||
)}
|
||
</Sheet>
|
||
);
|
||
}
|