271 lines
14 KiB
TypeScript
271 lines
14 KiB
TypeScript
import { useState } from 'react';
|
||
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||
import { api, type Plan } from '@/api';
|
||
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 'سهماهه';
|
||
return days ? `${toFa(days)} روزه` : '';
|
||
}
|
||
|
||
/**
|
||
* «بستهی سوال» (از ۱۴۰۵-۰۷ بهجای اشتراکِ ماهانه/سالانه). هر کس به اندازهی مصرفش سوال از
|
||
* دستیار میخرد؛ اعتبار منقضی نمیشود و هر بسته چند روز قلبِ نامحدود هم هدیه دارد. هر روز
|
||
* اول سوالهای رایگانِ همان روز خرج میشوند، بعد اعتبار.
|
||
*/
|
||
export function SubscriptionScreen() {
|
||
const plans = useAsync(() => api.subscription.plans(), []);
|
||
const status = useAsync(() => api.subscription.status(), []);
|
||
const quota = useAsync(() => api.ai.quota(), []);
|
||
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(); };
|
||
|
||
return (
|
||
<Screen>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<ScreenHeader title="بستهی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" 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>
|
||
|
||
{plans.loading ? (
|
||
<LoadingState text="دارم بستهها رو میارم…" />
|
||
) : plans.error ? (
|
||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||
) : !plans.data?.plans.length ? (
|
||
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
||
) : (
|
||
plans.data.plans.map((plan) => (
|
||
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
|
||
))
|
||
)}
|
||
|
||
<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 PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
|
||
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>
|
||
</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>
|
||
);
|
||
}
|