Checkout: Zarinpal is the default; Bazaar tile shows the price Bazaar itself has (Poolakey getSkuDetails bridge)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 18:21:48 +03:30
parent fffdeac3ed
commit d440986b88
4 changed files with 115 additions and 11 deletions

View File

@@ -3,7 +3,7 @@ import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag,
import { api, type Plan, type SubscriptionStatus } from '@/api';
import { daysLeft } from '@/lib/subscription';
import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { useBackStep } from '@/nav/Navigator';
@@ -42,6 +42,8 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const [view, setView] = useState<View | null>(initialView ?? null);
const [picked, setPicked] = useState<string | null>(null);
const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپی که از بازار می‌خرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار می‌گیرد.
const [storePrices, setStorePrices] = useState<Record<string, string>>({});
const look = useCompanion();
// از فهرست، «برگشت» اول به دو گزینه برمی‌گردد.
useBackStep(view !== null && !initialView, () => setView(null));
@@ -54,6 +56,15 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
}, [status.reload]);
const all = plans.data?.plans ?? [];
useEffect(() => {
if (!canBuyFromBazaar() || !plans.data) return;
const skus = (kind: Plan['kind']) => plans.data!.plans.filter((p) => p.kind === kind && p.bazaarSku).map((p) => p.bazaarSku);
let alive = true;
void Promise.all([bazaarPrices(skus('subscription'), 'subscription'), bazaarPrices(skus('pack'), 'inapp')]).then(
([subs, packs]) => alive && setStorePrices({ ...subs, ...packs }),
);
return () => { alive = false; };
}, [plans.data]);
const list = all.filter((p) => p.kind === view);
const monthly = all.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
const cheapestSub = all.filter((p) => p.kind === 'subscription').sort((x, y) => x.price - y.price)[0];
@@ -126,7 +137,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
</div>
)}
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
<CheckoutSheet plan={buying} storePrices={storePrices} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
</Screen>
);
}
@@ -274,11 +285,11 @@ function bazaarBlocker(plan: Plan | null): string | null {
* جابه‌جاییِ همین صفحه است.
*
* دو روشِ پرداخت همیشه کنارِ هم دیده می‌شوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار این‌جا ممکن نیست، کاشیِ
* آن غیرفعال است و دلیلش را زیرِ نامش می‌گوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). داخلِ اپی که
* از بازار می‌خرد پیش‌فرض بازار است. ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ
* آن غیرفعال است و دلیلش را زیرِ نامش می‌گوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). پیش‌فرض زرین‌پال
* است؛ داخلِ اپ، زیرِ «کافه‌بازار» قیمتی می‌آید که خودِ بازار برای آن SKU دارد. ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ
* درون‌برنامه‌ای را محدود می‌کند؛ نگه داشتنِ زرین‌پال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده.
*/
function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose: () => void; onPaid: () => void }) {
function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | null; storePrices: Record<string, string>; onClose: () => void; onPaid: () => void }) {
const blocker = bazaarBlocker(plan);
const [method, setMethod] = useState<Method>('gateway');
const [code, setCode] = useState('');
@@ -288,10 +299,11 @@ function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose:
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
const isSubscription = plan?.kind === 'subscription';
// هر بار که برگه برای پلنی باز می‌شود: داخلِ اپِ بازار، بازار پیش‌فرض است.
// هر بار که برگه برای پلنی باز می‌شود، زرین‌پال انتخاب است (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ بازار یک لمس آن‌طرف‌تر.
useEffect(() => {
if (plan) setMethod(bazaarBlocker(plan) ? 'gateway' : 'bazaar');
if (plan) setMethod('gateway');
}, [plan]);
const storePrice = plan?.bazaarSku ? storePrices[plan.bazaarSku] : undefined;
/**
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
@@ -409,13 +421,13 @@ function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose:
<>
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3.5">
<span className="text-sm font-bold text-body">مبلغ</span>
<span className="text-lg font-black text-ink">{price(plan.price)}</span>
<span className="text-lg font-black text-ink">{method === 'bazaar' && storePrice ? storePrice : price(plan.price)}</span>
</div>
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4">
<span className="mb-2 block text-sm font-black text-ink">با چی پرداخت می‌کنی؟</span>
<div className="grid grid-cols-2 gap-2">
<MethodTile selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={22} />} title="کارتِ بانکی" note="زرین‌پال" />
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافه‌بازار" note={blocker ?? 'با حسابِ بازارت'} disabled={!!blocker} />
<MethodTile selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={22} />} title="کافه‌بازار" note={blocker ?? storePrice ?? 'با حسابِ بازارت'} disabled={!!blocker} />
</div>
</div>
{showCode ? (