forked from Tadrisino/MathApp_frontend
Subscriptions only: no question credits anywhere; Bazaar bridge is subscribeProduct only (bazaarBuy(sku, orderId, token), bazaarPrices(skusJson)), no consume
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -241,8 +241,6 @@ export interface AskDone {
|
||||
suggestedFollowUps: string[];
|
||||
/** سوالهای رایگانِ باقیِ امروز. */
|
||||
remainingQuota: number;
|
||||
/** اعتبارِ بستههای سوال؛ منقضی نمیشود. */
|
||||
credits?: number;
|
||||
usedChunks: UsedChunk[];
|
||||
diagram?: Diagram | null;
|
||||
}
|
||||
@@ -281,13 +279,7 @@ export interface Plan {
|
||||
badge: string;
|
||||
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
||||
aiDailyQuota: number;
|
||||
/** «pack» بستهی سوال؛ «subscription» اشتراکِ زمانی که همهی فصلها را باز میکند. */
|
||||
kind: 'pack' | 'subscription';
|
||||
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
||||
questionCredits: number;
|
||||
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
|
||||
heartsDays: number;
|
||||
/** SKUِ همین پلن (بسته یا اشتراک) در کافهبازار؛ خالی یعنی از بازار خریدنی نیست. */
|
||||
/** SKUِ همین اشتراک در کافهبازار؛ خالی یعنی از بازار خریدنی نیست. */
|
||||
bazaarSku: string;
|
||||
}
|
||||
|
||||
@@ -298,7 +290,7 @@ export interface PlansResponse {
|
||||
}
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه)، نه بسته. */
|
||||
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه). */
|
||||
active: boolean;
|
||||
/** نامِ پلنِ فعال؛ null بیاشتراک. */
|
||||
planTitle: string | null;
|
||||
@@ -308,17 +300,12 @@ export interface SubscriptionStatus {
|
||||
aiDailyQuota: number;
|
||||
planId: string | null;
|
||||
expiresAt: string | null;
|
||||
/** سوالهای خریدهشدهی باقیمانده. */
|
||||
questionCredits: number;
|
||||
/** پایانِ قلبِ نامحدودِ هدیهی بستهها؛ null یعنی ندارد. */
|
||||
unlimitedHeartsUntil: string | null;
|
||||
}
|
||||
|
||||
/** GET /ai/quota */
|
||||
export interface TutorQuota {
|
||||
freeDaily: number;
|
||||
remainingQuota: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
@@ -327,15 +314,12 @@ export interface PurchaseResponse {
|
||||
paymentUrl: string | null;
|
||||
/** فقط بازار: SKUی که اپِ اندروید میخرد. */
|
||||
bazaarSku?: string;
|
||||
/** فقط بازار: «inapp» بسته (بعد از تأیید consume)، «subscription» اشتراک. */
|
||||
bazaarProductType?: 'inapp' | 'subscription';
|
||||
}
|
||||
|
||||
/** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحهی خریدِ بازار. */
|
||||
export interface BazaarStartResponse {
|
||||
orderId: string;
|
||||
sku: string;
|
||||
productType: 'inapp' | 'subscription';
|
||||
/** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */
|
||||
amount: number;
|
||||
/** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را میگیرد. */
|
||||
|
||||
@@ -8,12 +8,11 @@ import { store } from './storage';
|
||||
* جایی است که با آن حرف میزند. در مرورگرِ معمولی آن شیء نیست و خرید با زرینپال است.
|
||||
*
|
||||
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
|
||||
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
|
||||
* productType «inapp» (بسته، purchaseProduct) یا «subscription» (subscribeProduct)؛
|
||||
* orderId همان payloadِ خرید است؛ dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) فقط بسته، بعد از تأییدِ سرور.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson, productType) قیمتِ پیشخان؛ جواب با رویدادِ
|
||||
* «tadrisino:bazaar-prices» و detailِ { productType, prices: { sku: «۷۹۸,۰۰۰ تومان» } } یا { productType, error }
|
||||
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
|
||||
* خریدِ اشتراک با subscribeProduct (فقط اشتراک فروخته میشود)؛ orderId همان payloadِ خرید است؛
|
||||
* dynamicPriceToken رشتهی خالی یعنی قیمتِ پیشخان.
|
||||
* JS → اندروید TadrisinoAndroid.bazaarPrices(skusJson) قیمتِ پیشخان؛ جواب با رویدادِ «tadrisino:bazaar-prices»
|
||||
* و detailِ { prices: { sku: «۸۹۸,۰۰۰ تومان» } } یا { error }
|
||||
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
|
||||
* { orderId, purchaseToken } خرید انجام شد
|
||||
* { orderId, error: 'canceled' | 'failed', message? }
|
||||
@@ -23,14 +22,11 @@ import { store } from './storage';
|
||||
* هیچکدام از اینها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
|
||||
*/
|
||||
|
||||
export type BazaarProductType = 'inapp' | 'subscription';
|
||||
|
||||
interface AndroidBridge {
|
||||
bazaarBuy?: (sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string) => void;
|
||||
bazaarBuy?: (sku: string, orderId: string, dynamicPriceToken: string) => void;
|
||||
/** APKِ قدیمی؛ فقط برای اینکه بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
|
||||
bazaarPurchase?: (sku: string, orderId: string) => void;
|
||||
bazaarConsume?: (purchaseToken: string) => void;
|
||||
bazaarPrices?: (skusJson: string, productType: BazaarProductType) => void;
|
||||
bazaarPrices?: (skusJson: string) => void;
|
||||
/** اولین صفحهی واقعی کشیده شد؛ اسپلشِ سیستم میتواند برود (APKهای قدیمیتر این را ندارند). */
|
||||
appReady?: () => void;
|
||||
}
|
||||
@@ -56,7 +52,7 @@ export function inAndroidApp(): boolean {
|
||||
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
|
||||
}
|
||||
|
||||
/** اپی که میتواند از بازار بخرد (بسته و اشتراک). */
|
||||
/** اپی که میتواند از بازار اشتراک بخرد. */
|
||||
export function canBuyFromBazaar(): boolean {
|
||||
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
|
||||
}
|
||||
@@ -65,7 +61,7 @@ export function canBuyFromBazaar(): boolean {
|
||||
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/** خرید را به اپِ اندروید میسپارد و purchaseToken را برمیگرداند. */
|
||||
export function bazaarBuy(sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string | null): Promise<string> {
|
||||
export function bazaarBuy(sku: string, orderId: string, dynamicPriceToken: string | null): Promise<string> {
|
||||
const bridge = window.TadrisinoAndroid;
|
||||
if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -83,16 +79,16 @@ export function bazaarBuy(sku: string, orderId: string, productType: BazaarProdu
|
||||
}
|
||||
window.addEventListener('tadrisino:bazaar', onResult);
|
||||
try {
|
||||
bridge.bazaarBuy!(sku, orderId, productType, dynamicPriceToken ?? '');
|
||||
bridge.bazaarBuy!(sku, orderId, dynamicPriceToken ?? '');
|
||||
} catch (err) {
|
||||
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type PricesResult = { productType: BazaarProductType; prices?: Record<string, string>; error?: string };
|
||||
type PricesResult = { prices?: Record<string, string>; error?: string };
|
||||
|
||||
function askPrices(skus: string[], productType: BazaarProductType): Promise<Record<string, string>> {
|
||||
function askPrices(skus: string[]): Promise<Record<string, string>> {
|
||||
const bridge = window.TadrisinoAndroid;
|
||||
if (!bridge?.bazaarPrices || !skus.length) return Promise.resolve({});
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -104,12 +100,12 @@ function askPrices(skus: string[], productType: BazaarProductType): Promise<Reco
|
||||
}
|
||||
function onResult(event: Event) {
|
||||
const detail = (event as CustomEvent<PricesResult>).detail;
|
||||
if (!detail || detail.productType !== productType) return;
|
||||
if (!detail) return;
|
||||
if (detail.prices) done(() => resolve(detail.prices!));
|
||||
else done(() => reject(new Error(detail.error || 'failed')));
|
||||
}
|
||||
window.addEventListener('tadrisino:bazaar-prices', onResult);
|
||||
bridge.bazaarPrices!(JSON.stringify(skus), productType);
|
||||
bridge.bazaarPrices!(JSON.stringify(skus));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,13 +113,13 @@ function askPrices(skus: string[], productType: BazaarProductType): Promise<Reco
|
||||
* قیمتِ ثبتشده در پیشخانِ بازار برای هر SKU، از خودِ بازار روی گوشی (API سرور قیمت نمیدهد). اتصالِ Poolakey
|
||||
* چند لحظه بعد از باز شدنِ اپ برقرار میشود، پس یک بار دوباره امتحان میشود. خطا یعنی قیمتی نشان داده نمیشود.
|
||||
*/
|
||||
export async function bazaarPrices(skus: string[], productType: BazaarProductType): Promise<Record<string, string>> {
|
||||
export async function bazaarPrices(skus: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
return await askPrices(skus, productType);
|
||||
return await askPrices(skus);
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
try {
|
||||
return await askPrices(skus, productType);
|
||||
return await askPrices(skus);
|
||||
} catch (err) {
|
||||
console.error('bazaarPrices failed', err);
|
||||
return {};
|
||||
@@ -131,15 +127,6 @@ export async function bazaarPrices(skus: string[], productType: BazaarProductTyp
|
||||
}
|
||||
}
|
||||
|
||||
export function bazaarConsume(purchaseToken: string) {
|
||||
try {
|
||||
window.TadrisinoAndroid?.bazaarConsume?.(purchaseToken);
|
||||
} catch (err) {
|
||||
// مصرف نشدن یعنی خریدِ دوبارهی همین بسته تا مصرفِ بعدی گیر میکند؛ اعتبارِ کاربر سالم است.
|
||||
console.error('bazaarConsume failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotency-Keyِ خریدِ نیمهتمامِ هر SKU. اگر بازار پول را گرفت ولی تأییدِ سرور نرسید (قطعِ
|
||||
* اینترنت)، تلاشِ بعدی باید همان سفارش را بگیرد؛ سفارشِ تازه با توکنِ قبلی تأیید نمیشد چون هر
|
||||
|
||||
@@ -25,13 +25,13 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
const chat = useAskState();
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
// «X سوالِ رایگانِ امروز + Y سوالِ خریدهشده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
|
||||
// «X سوالِ امروز»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||||
const [quota, setQuota] = useState<number | null>(null);
|
||||
const [quotaDone, setQuotaDone] = useState(false);
|
||||
useEffect(() => {
|
||||
api.ai
|
||||
.quota()
|
||||
.then((q) => setQuota({ free: q.remainingQuota, credits: q.credits }))
|
||||
.then((q) => setQuota(q.remainingQuota))
|
||||
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
|
||||
.finally(() => setQuotaDone(true));
|
||||
}, []);
|
||||
@@ -109,7 +109,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
diagram: done.diagram ?? null,
|
||||
});
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
|
||||
setQuota(done.remainingQuota);
|
||||
} catch (err) {
|
||||
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||||
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
|
||||
@@ -117,7 +117,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
text: friendlyError(err, 'جوابی نرسید'),
|
||||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
|
||||
});
|
||||
if (quotaReached) setQuota({ free: 0, credits: 0 });
|
||||
if (quotaReached) setQuota(0);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
|
||||
>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />}
|
||||
{quota !== null && <QuotaPills free={quota} />}
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -355,23 +355,21 @@ function Message({
|
||||
}
|
||||
|
||||
/**
|
||||
* شمارنده در سربرگِ دستیار: سوالهای رایگانِ باقیِ امروز، و اگر از بستههای قدیمی سوالی خریده و مانده، آن هم جدا.
|
||||
* بسته از ۲۰۲۶-۱۰-۱۲ فروخته نمیشود؛ وقتی رایگانِ امروز تمام شد، دکمهی دوم به فروشگاهِ اشتراک میبرد.
|
||||
* شمارنده در سربرگِ دستیار: سوالهای باقیِ امروز. وقتی تمام شد، دکمهی دوم به فروشگاهِ اشتراک میبرد
|
||||
* (سوال جدا فروخته نمیشود؛ مشترک فردا دوباره سهم دارد).
|
||||
*/
|
||||
function QuotaPills({ free, credits }: { free: number; credits: number }) {
|
||||
function QuotaPills({ free }: { free: number }) {
|
||||
const nav = useNav();
|
||||
return (
|
||||
<>
|
||||
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
|
||||
{free > 0 ? <>{toFa(free)} سوالِ امروز</> : 'سوالهای امروز تموم شد'}
|
||||
</span>
|
||||
{credits > 0 ? (
|
||||
<span className="rounded-full bg-white px-3 py-1.5 text-xs font-black text-violet">{toFa(credits)} سوالِ خریدهشده</span>
|
||||
) : free === 0 ? (
|
||||
{free === 0 && (
|
||||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
|
||||
سوالِ بیشتر با اشتراک
|
||||
</button>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,11 +185,6 @@ function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
|
||||
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همهی فصلها و قلبِ نامحدود باز میشه.</span>
|
||||
)}
|
||||
</div>
|
||||
{s.questionCredits > 0 && (
|
||||
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
|
||||
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریدهشده در اعتبارت
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Check, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
|
||||
import { Check, Crown, CreditCard, ShieldCheck, ShoppingBag, Ticket } from 'lucide-react';
|
||||
import { api, type Plan, type SubscriptionStatus } from '@/api';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
|
||||
import { BazaarPurchaseError, bazaarBuy, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } 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, formatNumber, formatToman, toFa } from '@/lib/format';
|
||||
import { formatNumber, formatToman, toFa } from '@/lib/format';
|
||||
import { useCompanion } from '@/state/companion';
|
||||
|
||||
function durationLabel(days: number): string {
|
||||
@@ -45,12 +45,12 @@ export function SubscriptionScreen() {
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
}, [status.reload]);
|
||||
|
||||
const list = (plans.data?.plans ?? []).filter((p) => p.kind === 'subscription');
|
||||
const list = plans.data?.plans ?? [];
|
||||
useEffect(() => {
|
||||
const skus = list.filter((p) => p.bazaarSku).map((p) => p.bazaarSku);
|
||||
if (!canBuyFromBazaar() || !skus.length) return;
|
||||
let alive = true;
|
||||
void bazaarPrices(skus, 'subscription').then((prices) => alive && setStorePrices(prices));
|
||||
void bazaarPrices(skus).then((prices) => alive && setStorePrices(prices));
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plans.data]);
|
||||
@@ -102,59 +102,37 @@ export function SubscriptionScreen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بیاشتراک: هیچ کارتی (دو گزینهی زیر
|
||||
* خودشان حرف را میزنند)، مگر سوالِ خریدهشده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هماندازه
|
||||
* میآید، نه «نداری» که برای مشترک غلط بود.
|
||||
* وضعیتِ کاربر بالای فروشگاه: مشترک یک کارتِ واضح با روزهای مانده میبیند؛ بیاشتراک هیچ کارتی (کارتهای
|
||||
* اشتراکِ زیر خودشان حرف را میزنند). تا وضعیت نرسیده جای خالیِ هماندازه میآید، نه «نداری» که برای مشترک غلط بود.
|
||||
*/
|
||||
function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) {
|
||||
if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />;
|
||||
if (!status) return null;
|
||||
const days = status.active ? daysLeft(status.expiresAt) : null;
|
||||
const hearts = !status.active && status.unlimitedHeartsUntil;
|
||||
const extras = (
|
||||
<>
|
||||
{status.questionCredits > 0 && <Pill icon={<Sparkles size={14} />} text={`${toFa(status.questionCredits)} سوالِ خریدهشده`} />}
|
||||
{hearts && <Pill icon={<Heart size={14} fill="currentColor" />} text={`قلبِ نامحدود تا ${formatJalaliDate(status.unlimitedHeartsUntil!)}`} />}
|
||||
</>
|
||||
);
|
||||
if (!status.active) {
|
||||
return status.questionCredits > 0 || hearts ? <div className="flex flex-wrap gap-2">{extras}</div> : null;
|
||||
}
|
||||
if (!status?.active) return null;
|
||||
const days = daysLeft(status.expiresAt);
|
||||
const total = status.planDurationDays ?? 30;
|
||||
const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)));
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="rounded-3xl bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] p-4 text-white shadow-lg shadow-orange/25">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-white/20"><Crown size={24} /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-black">اشتراکت فعاله</div>
|
||||
<div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
|
||||
<div className="rounded-3xl bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] p-4 text-white shadow-lg shadow-orange/25">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-white/20"><Crown size={24} /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-black">اشتراکت فعاله</div>
|
||||
<div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
|
||||
</div>
|
||||
{days !== null && (
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-black leading-none">{toFa(days)}</div>
|
||||
<div className="mt-1 text-xs text-white/85">روز مونده</div>
|
||||
</div>
|
||||
{days !== null && (
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-black leading-none">{toFa(days)}</div>
|
||||
<div className="mt-1 text-xs text-white/85">روز مونده</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
|
||||
<div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
|
||||
<div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
{(status.questionCredits > 0) && <div className="flex flex-wrap gap-2">{extras}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ icon, text }: { icon: ReactNode; text: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-sm font-bold text-violet ring-1 ring-line">
|
||||
{icon} {text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک میدهد (خواستِ سینیور: روی هر کارت). */
|
||||
function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan: Plan; assistant: string; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
|
||||
const months = Math.round(plan.durationDays / 30);
|
||||
@@ -228,7 +206,6 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
|
||||
const isSubscription = plan?.kind === 'subscription';
|
||||
|
||||
// هر بار که برگه برای پلنی باز میشود، زرینپال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آنطرفتر.
|
||||
useEffect(() => {
|
||||
@@ -238,7 +215,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
|
||||
/**
|
||||
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
|
||||
* → سنجشِ سرور → مصرفِ خرید (فقط بسته؛ اشتراک مصرف نمیشود).
|
||||
* → سنجشِ سرور. اشتراکِ بازار مصرف (consume) نمیشود؛ بازار خودش آخرِ هر دوره تمدیدش میکند.
|
||||
*/
|
||||
async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> {
|
||||
const sku = plan.bazaarSku;
|
||||
@@ -269,7 +246,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
const start = await api.subscription.startBazaar(order.orderId);
|
||||
let token: string;
|
||||
try {
|
||||
token = await bazaarBuy(start.sku, order.orderId, start.productType, start.dynamicPriceToken);
|
||||
token = await bazaarBuy(start.sku, order.orderId, start.dynamicPriceToken);
|
||||
} catch (err) {
|
||||
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
|
||||
throw err;
|
||||
@@ -277,7 +254,6 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
const result = await api.subscription.verifyBazaar(order.orderId, token);
|
||||
if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed');
|
||||
bazaarPending.clear(sku);
|
||||
if (start.productType === 'inapp') bazaarConsume(token);
|
||||
return 'bazaar';
|
||||
}
|
||||
|
||||
@@ -338,8 +314,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
const doneText =
|
||||
done === 'gateway' ? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
|
||||
: isSubscription ? 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش میکنه؛ لغوِ تمدید از بخشِ «اشتراکها»ی خودِ بازاره.'
|
||||
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
|
||||
: 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش میکنه؛ لغوِ تمدید از بخشِ «اشتراکها»ی خودِ بازاره.';
|
||||
|
||||
return (
|
||||
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}` : ''}>
|
||||
@@ -385,7 +360,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||
<ShieldCheck size={14} />
|
||||
{method === 'bazaar'
|
||||
? isSubscription ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید میشه.' : 'پرداخت در خودِ بازار انجام میشه.'
|
||||
? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید میشه.'
|
||||
: 'پرداخت در درگاهِ امنِ زرینپال انجام میشه.'}
|
||||
</p>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user