Bazaar: subscriptions (subscribeProduct), dynamic price token, RSA key; Zarinpal and Bazaar side by side at checkout

- Checkout sheet always shows both methods as two tiles; Bazaar tile disabled with the reason
  (not in Bazaar / Android app only / update the app) instead of hidden; default is Bazaar inside an app that can buy.
- Bridge v2: TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken); inAndroidApp() split
  from canBuyFromBazaar() so the pay.html workaround still applies to the installed APK.
- Bazaar flow: order -> optional discount code -> POST /payment/bazaar/orders/{id}/start -> Poolakey -> verify;
  consume only packs.
- Android: RSA key, subscribeProduct/getSubscribedProducts for subscriptions, dynamicPriceToken, versionCode 2.
  compileDebugKotlin green; no purchase tested on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:51:03 +03:30
parent 3a2a685ed3
commit fffdeac3ed
7 changed files with 174 additions and 93 deletions

View File

@@ -200,6 +200,8 @@ export const api = {
*/
purchaseBazaar: (planId: string, idempotencyKey: string) =>
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
/** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمی‌نشیند. */
startBazaar: (orderId: string) => http.post<T.BazaarStartResponse>(`/payment/bazaar/orders/${seg(orderId)}/start`),
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */
verifyBazaar: (orderId: string, purchaseToken: string) =>
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),

View File

@@ -287,7 +287,7 @@ export interface Plan {
questionCredits: number;
/** بسته: روزهای قلبِ نامحدودِ هدیه. */
heartsDays: number;
/** SKUِ همین بسته در کافه‌بازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */
/** SKUِ همین پلن (بسته یا اشتراک) در کافه‌بازار؛ خالی یعنی از بازار خریدنی نیست. */
bazaarSku: string;
}
@@ -327,6 +327,19 @@ 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 یعنی بازار قیمتِ پیشخان را می‌گیرد. */
dynamicPriceToken: string | null;
}
export interface BazaarVerifyResponse {

View File

@@ -5,22 +5,27 @@ import { store } from './storage';
*
* خریدِ درون‌برنامه‌ایِ بازار فقط از کدِ بومیِ اندروید (کتابخانه‌ی Poolakey) شدنی است، پس
* اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه می‌گذارد و این فایل تنها
* جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و
* خرید مثلِ قبل به زرین‌پال می‌رود.
* جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست و خرید با زرین‌پال است.
*
* قرارداد (سمتِ اندروید: `android/` در همین ریپو):
* JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان
* developerPayloadِ خرید است.
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن
* همان بسته دوباره خریدنی نیست.
* JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
* productType «inapp» (بسته، purchaseProduct) یا «subscription» (subscribeProduct)؛
* orderId همان payloadِ خرید است؛ dynamicPriceToken رشته‌ی خالی یعنی قیمتِ پیشخان.
* JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) فقط بسته، بعد از تأییدِ سرور.
* اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail:
* { orderId, purchaseToken } خرید انجام شد
* { orderId, error: 'canceled' | 'failed', message? }
*
* هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده.
* `bazaarBuy` از APKهای پس از ۲۰۲۶-۱۰-۱۲ است. APKهای قبلی کلیدِ RSA نداشتند و Poolakey در آن‌ها خاموش بود، پس از
* بازار هیچ خریدی نکرده‌اند؛ در آن‌ها کاشیِ بازار غیرفعال است و «اپ را به‌روز کن» می‌گوید.
* هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲).
*/
export type BazaarProductType = 'inapp' | 'subscription';
interface AndroidBridge {
bazaarBuy?: (sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string) => void;
/** APKِ قدیمی؛ فقط برای این‌که بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */
bazaarPurchase?: (sku: string, orderId: string) => void;
bazaarConsume?: (purchaseToken: string) => void;
/** اولین صفحه‌ی واقعی کشیده شد؛ اسپلشِ سیستم می‌تواند برود (APKهای قدیمی‌تر این را ندارند). */
@@ -43,17 +48,23 @@ export class BazaarPurchaseError extends Error {
}
}
export function inBazaarApp(): boolean {
return typeof window.TadrisinoAndroid?.bazaarPurchase === 'function';
/** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخه‌ای). */
export function inAndroidApp(): boolean {
return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null;
}
/** اپی که می‌تواند از بازار بخرد (بسته و اشتراک). */
export function canBuyFromBazaar(): boolean {
return typeof window.TadrisinoAndroid?.bazaarBuy === 'function';
}
/** صفحه‌ی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را می‌گیرد. */
const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000;
/** خرید را به اپِ اندروید می‌سپارد و purchaseToken را برمی‌گرداند. */
export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
export function bazaarBuy(sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string | null): Promise<string> {
const bridge = window.TadrisinoAndroid;
if (!bridge?.bazaarPurchase) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app'));
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS);
function finish(settle: () => void) {
@@ -69,7 +80,7 @@ export function bazaarPurchase(sku: string, orderId: string): Promise<string> {
}
window.addEventListener('tadrisino:bazaar', onResult);
try {
bridge.bazaarPurchase!(sku, orderId);
bridge.bazaarBuy!(sku, orderId, productType, dynamicPriceToken ?? '');
} catch (err) {
finish(() => reject(new BazaarPurchaseError('failed', String(err))));
}

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, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { useBackStep } from '@/nav/Navigator';
@@ -34,7 +34,7 @@ type View = 'subscription' | 'pack';
*
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیج‌کننده»): مزیت‌های اشتراک یک بار بالای فهرست
* می‌آیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
* انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگه‌ی پرداخت انتخاب می‌شود.
* انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت (زرین‌پال یا کافه‌بازار) در برگه‌ی پرداخت انتخاب می‌شود.
*/
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
@@ -42,7 +42,6 @@ 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);
const bazaar = inBazaarApp();
const look = useCompanion();
// از فهرست، «برگشت» اول به دو گزینه برمی‌گردد.
useBackStep(view !== null && !initialView, () => setView(null));
@@ -127,7 +126,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
</div>
)}
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
</Screen>
);
}
@@ -259,28 +258,46 @@ function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; month
);
}
type Method = 'gateway' | 'bazaar';
/** چرا بازار برای این پلن و این‌جا ممکن نیست؛ null یعنی ممکن است. */
function bazaarBlocker(plan: Plan | null): string | null {
if (!plan?.bazaarSku) return 'این مورد در بازار نیست';
if (!inAndroidApp()) return 'فقط در اپِ اندروید';
if (!canBuyFromBazaar()) return 'اپ رو از بازار به‌روز کن';
return null;
}
/**
* خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود،
* و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را
* Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است.
* خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود، و بعد کاربر به درگاه یا به
* صفحه‌ی خریدِ بازار می‌رود. window.open بعد از await را Safariِ آیفون مسدود می‌کند؛ برای همین درگاه
* جابه‌جاییِ همین صفحه است.
*
* دو روشِ پرداخت همیشه کنارِ هم دیده می‌شوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار این‌جا ممکن نیست، کاشیِ
* آن غیرفعال است و دلیلش را زیرِ نامش می‌گوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). داخلِ اپی که
* از بازار می‌خرد پیش‌فرض بازار است. ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ
* درون‌برنامه‌ای را محدود می‌کند؛ نگه داشتنِ زرین‌پال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده.
*/
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
// داخلِ اپِ بازار دو راه هست (خواستِ مالک، ۱۴۰۵-۰۷): درگاهِ بانکی پیش‌فرض است، بازار فقط برای بسته‌ای که SKU دارد.
// ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ این انتخاب تصمیمِ مالک است
// و با بازار هماهنگ نشده.
const canBazaar = bazaar && !!plan?.bazaarSku;
const [method, setMethod] = useState<'gateway' | 'bazaar'>('gateway');
function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose: () => void; onPaid: () => void }) {
const blocker = bazaarBlocker(plan);
const [method, setMethod] = useState<Method>('gateway');
const [code, setCode] = useState('');
const [showCode, setShowCode] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
const isSubscription = plan?.kind === 'subscription';
// هر بار که برگه برای پلنی باز می‌شود: داخلِ اپِ بازار، بازار پیش‌فرض است.
useEffect(() => {
if (plan) setMethod(bazaarBlocker(plan) ? 'gateway' : 'bazaar');
}, [plan]);
/**
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
* کدِ تخفیف این‌جا نیست: بازار قیمتِ ثابتِ محصول را می‌گیرد و سرور تخفیف را رد می‌کند.
* بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار
* → سنجشِ سرور → مصرفِ خرید (فقط بسته؛ اشتراک مصرف نمی‌شود).
*/
async function payWithBazaar(plan: Plan) {
async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> {
const sku = plan.bazaarSku;
let key = bazaarPending.get(sku);
if (key) {
@@ -295,27 +312,40 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
bazaarPending.set(sku, key);
}
const order = await api.subscription.purchaseBazaar(plan.id, key);
if (code.trim()) {
const current = await api.subscription.order(order.orderId);
// سفارشِ مانده از تلاشِ قبلی ممکن است همین کد را از قبل گرفته باشد.
if (current.amount === plan.price) {
const redeemed = await api.subscription.redeem(code.trim(), order.orderId);
if (redeemed.orderStatus === 'paid') {
bazaarPending.clear(sku);
return 'code';
}
}
}
const start = await api.subscription.startBazaar(order.orderId);
let token: string;
try {
token = await bazaarPurchase(order.bazaarSku || sku, order.orderId);
token = await bazaarBuy(start.sku, order.orderId, start.productType, start.dynamicPriceToken);
} catch (err) {
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false;
if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null;
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;
if (start.productType === 'inapp') bazaarConsume(token);
return 'bazaar';
}
async function pay() {
if (!plan || busy) return;
setBusy(true);
setError('');
if (canBazaar && method === 'bazaar') {
if (method === 'bazaar') {
try {
if (await payWithBazaar(plan)) setDone('bazaar');
const outcome = await payWithBazaar(plan);
if (outcome) setDone(outcome);
} catch (err) {
setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.'));
}
@@ -336,8 +366,8 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
if (bazaar && !shell?.paysInApp?.()) {
// اپِ اندرویدِ فعلی هر آدرسِ بیرونی را در مرورگرِ گوشی باز می‌کند و آن مرورگر Referer نمی‌فرستد؛ زرین‌پال
if (inAndroidApp() && !shell?.paysInApp?.()) {
// اپِ اندرویدِ قدیمی هر آدرسِ بیرونی را در مرورگرِ گوشی باز می‌کند و آن مرورگر Referer نمی‌فرستد؛ زرین‌پال
// بی‌Referer می‌گوید «دسترسی از این دامنه مجاز نمی‌باشد». پس اول pay.htmlِ خودمان در مرورگر باز می‌شود
// (src/pay.ts) و آن به درگاه می‌رود. «http://» عمداً است: WebView فقط https‌ِ همین دامنه را در خودش نگه
// می‌دارد؛ آروان بلافاصله به https می‌برد و لینک در fragment است، پس به سرور نمی‌رسد.
@@ -360,18 +390,19 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
setShowCode(false);
setError('');
setDone(null);
setMethod('gateway');
};
const doneText =
done === 'gateway' ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.'
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.'
: isSubscription ? 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش می‌کنه؛ لغوِ تمدید از بخشِ «اشتراک‌ها»ی خودِ بازاره.'
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
return (
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? (plan.kind === 'pack' ? `${toFa(plan.questionCredits)} سوال` : `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}`) : ''}>
{done ? (
<>
<p className="mb-5 text-sm leading-7 text-muted">
{done === 'gateway'
? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.'
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
</p>
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
</>
) : plan && (
@@ -380,14 +411,14 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
<span className="text-sm font-bold text-body">مبلغ</span>
<span className="text-lg font-black text-ink">{price(plan.price)}</span>
</div>
{canBazaar && (
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4 flex flex-col gap-2">
<span className="text-sm font-black text-ink">با چی پرداخت می‌کنی؟</span>
<MethodRow selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={20} />} title="کارتِ بانکی" note="درگاهِ امنِ زرین‌پال" />
<MethodRow selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={20} />} title="کافه‌بازار" note="با حسابِ بازارت" />
<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} />
</div>
)}
{method === 'gateway' && (showCode ? (
</div>
{showCode ? (
<label className="mb-4 block">
<span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span>
<input
@@ -403,37 +434,45 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
<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>
{method === 'gateway' && (
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<ShieldCheck size={14} /> پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.
</p>
)}
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
<PrimaryButton loading={busy} onClick={() => void pay()}>
{method === 'bazaar' ? 'پرداخت با کافه‌بازار' : 'پرداخت با کارتِ بانکی'}
</PrimaryButton>
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<ShieldCheck size={14} />
{method === 'bazaar'
? isSubscription ? 'پرداخت در خودِ بازار؛ آخرِ هر دوره خودکار تمدید می‌شه.' : 'پرداخت در خودِ بازار انجام می‌شه.'
: 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p>
</>
)}
</Sheet>
);
}
function MethodRow({ selected, onSelect, icon, title, note }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string }) {
function MethodTile({ selected, onSelect, icon, title, note, disabled = false }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string; disabled?: boolean }) {
const on = selected && !disabled;
return (
<button
type="button"
role="radio"
aria-checked={selected}
aria-checked={on}
aria-disabled={disabled}
disabled={disabled}
onClick={onSelect}
className={`flex items-center gap-3 rounded-2xl border-2 px-4 py-3 text-right ${selected ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white'}`}
className={`relative flex flex-col items-center gap-1 rounded-2xl border-2 border-b-4 px-2 py-3 text-center transition-colors ${
disabled ? 'cursor-not-allowed border-line bg-soft opacity-70' : on ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white active:translate-y-0.5'
}`}
>
<span className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 ${selected ? 'border-coral bg-coral' : 'border-[#d6d3ea]'}`}>
{selected && <Check size={12} strokeWidth={4} className="text-white" />}
</span>
<span className={selected ? 'text-coral' : 'text-muted'}>{icon}</span>
<span className="flex-1">
<span className="block text-[15px] font-black text-ink">{title}</span>
<span className="block text-xs text-muted">{note}</span>
</span>
{on && (
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-coral text-white">
<Check size={12} strokeWidth={4} />
</span>
)}
<span className={on ? 'text-coral' : 'text-muted'}>{icon}</span>
<span className="text-[15px] font-black text-ink">{title}</span>
<span className="text-xs leading-5 text-muted">{note}</span>
</button>
);
}