Subscription store, paywall on locked units, profile subscription bar, Tadrisino branding
- Store opens with two choices (subscription / question packs); 3- and 6-month cards show per-month price and saving. - Units that need a subscription show a crown badge and open the store; a 402 from the quiz goes there too. «ادامه بده» skips locked units. - Profile: subscription card with an animated days-left bar (planDurationDays). - BrandMark (SVG) on splash, home header and footer; copyright footer; floating math glyphs on splash. CSS transform/opacity only; honours reduced motion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||||
import { api, type Plan } from '@/api';
|
||||
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';
|
||||
@@ -13,72 +14,96 @@ 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() {
|
||||
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 [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="بستهی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" exact mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
|
||||
<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">
|
||||
<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 bg-[#F5F1FF] text-violet"><Sparkles size={22} /></span>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-bold text-muted">اعتبارِ تو</div>
|
||||
<div className="text-2xl font-black text-ink">{toFa(credits)} <span className="text-sm font-bold text-body">سوال</span></div>
|
||||
</div>
|
||||
{quota.data && (
|
||||
<div className="text-left text-xs leading-6 text-muted">
|
||||
رایگانِ امروز<br />
|
||||
<b className="text-base text-ink">{toFa(quota.data.remainingQuota)}</b> از {toFa(quota.data.freeDaily)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 border-t border-line pt-3 text-xs leading-6 text-muted">
|
||||
هر روز {toFa(plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول رایگانه؛ بعدش از اعتبارت کم میشه. اعتبار هیچوقت منقضی نمیشه.
|
||||
</p>
|
||||
{heartsUntil && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-coral">
|
||||
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود تا {formatJalaliDate(heartsUntil)}
|
||||
</p>
|
||||
)}
|
||||
{status.data?.active && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-mint">
|
||||
<Crown size={13} /> اشتراکِ قبلیت هم فعاله{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}</>}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
|
||||
|
||||
{plans.loading ? (
|
||||
<LoadingState text="دارم بستهها رو میارم…" />
|
||||
) : plans.error ? (
|
||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||
) : !plans.data?.plans.length ? (
|
||||
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
||||
{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>
|
||||
) : (
|
||||
plans.data.plans.map((plan) => (
|
||||
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
|
||||
))
|
||||
<>
|
||||
{!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">
|
||||
@@ -92,7 +117,58 @@ export function SubscriptionScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
|
||||
function StatusCard({ status, credits, heartsUntil, quota }: {
|
||||
status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null;
|
||||
}) {
|
||||
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">
|
||||
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
||||
</div>
|
||||
</div>
|
||||
{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} /> اعتبار: {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';
|
||||
@@ -105,10 +181,12 @@ function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean;
|
||||
<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>
|
||||
<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">
|
||||
@@ -233,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
{done ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوالها به اعتبارت اضافه شدن.'}
|
||||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
||||
</p>
|
||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user