UI readability pass and Bazaar in-app purchase bridge
- muted text #9e9ec0 -> #727299 (contrast 2.6 -> 4.5 on white); tab labels 11px - map: chapter titles wrap to two lines instead of being cut off - reduced-motion respected (MotionConfig + CSS), focus-visible ring, no pull-to-refresh, short vibration on correct/wrong answers - src/lib/bazaar.ts: bridge to the Android WebView wrapper; checkout uses Bazaar IAP only inside that app, Zarinpal everywhere else (unchanged) - android/: WebView wrapper with Poolakey; written but never built Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,7 +76,7 @@ export function MapScreen() {
|
||||
)}
|
||||
</button>
|
||||
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
|
||||
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
|
||||
<div className="line-clamp-2 max-w-[170px] text-xs font-black leading-5 text-ink">{unit.title}</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
@@ -24,6 +26,9 @@ export function SubscriptionScreen() {
|
||||
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;
|
||||
@@ -71,19 +76,23 @@ export function SubscriptionScreen() {
|
||||
) : !plans.data?.plans.length ? (
|
||||
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
||||
) : (
|
||||
plans.data.plans.map((plan) => <PackCard key={plan.id} plan={plan} onBuy={() => setBuying(plan)} />)
|
||||
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-faint">پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.</p>
|
||||
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
||||
{bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); reload(); }} />
|
||||
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
|
||||
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';
|
||||
@@ -122,9 +131,10 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuy}
|
||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`}
|
||||
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>
|
||||
@@ -136,27 +146,70 @@ function PackCard({ plan, onBuy }: { plan: Plan; onBuy: () => void }) {
|
||||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
||||
*/
|
||||
function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; onClose: () => void; onPaidWithCode: () => void }) {
|
||||
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 [freeDone, setFreeDone] = useState(false);
|
||||
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') {
|
||||
setFreeDone(true);
|
||||
setDone('code');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!order.paymentUrl) throw new Error('order without paymentUrl');
|
||||
window.location.assign(order.paymentUrl);
|
||||
} catch (err) {
|
||||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||||
@@ -165,19 +218,21 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (freeDone) onPaidWithCode();
|
||||
if (done) onPaid();
|
||||
else onClose();
|
||||
setCode('');
|
||||
setShowCode(false);
|
||||
setError('');
|
||||
setFreeDone(false);
|
||||
setDone(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
||||
{freeDone ? (
|
||||
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
|
||||
{done ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.</p>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوالها به اعتبارت اضافه شدن.'}
|
||||
</p>
|
||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||
</>
|
||||
) : plan && (
|
||||
@@ -186,7 +241,7 @@ function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; o
|
||||
<span className="text-sm text-body">مبلغ</span>
|
||||
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
||||
</div>
|
||||
{showCode ? (
|
||||
{bazaar ? null : showCode ? (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user