Calmer shop with payment choice, step-by-step phone back, sound/haptics that reach the phone, live streak flame

- Shop: subscription perks shown once (each card repeated them, «۳ سوال در روز» twice), plans as selectable
  rows with one big buy button, prices as «۷۹۸ هزار تومان», a single clear status card for subscribers.
  Checkout inside the Bazaar app offers «کارتِ بانکی» (default) or «کافه‌بازار» for packs with a SKU;
  «به‌زودی در بازار» is gone. The gateway opens in the phone browser; status refreshes on return.
- Back button: every back-able level (open sheet, pushed screen, non-home tab, shop list) owns exactly one
  history entry created right after the tap; back closes the sheet, then the screen, then returns to Home,
  then leaves the app. (A re-pushed trap is skipped by Chrome's history intervention.)
- Sound: AudioContext unlocked on the first touch (it was created after a network wait, which WebView/Safari
  keep muted); softer notes, option tick, finish fanfare, streak whoosh. Haptics through the new
  TadrisinoAndroid.vibrate bridge when the APK has it, navigator.vibrate otherwise.
- Streak: animated flame with lit / at-risk (grey) / frozen (ice) states on Home and the streak screen,
  a companion reminder when the streak is at risk or frozen, and a full-screen «روزِ پیاپی» celebration
  after the first lesson of the day (needs backend 9024c6b: streak.activeToday / lost).
- Lessons: intro/example text with inline «۱. … ۲. …» renders as a numbered list (2,250 of 4,469 texts).
- Quiz: duplicate React key on the answer bubble fixed.
- android/MainActivity.kt: vibrate bridge and mediaPlaybackRequiresUserGesture=false. Not built; needs the
  next APK from the signing owner.

Checked in headless Chrome against the local backend (shop as a subscriber with the Bazaar bridge stubbed,
back sequences, quiz → streak celebration, frozen state). Not checked on a real phone or in the APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 18:27:24 +03:30
parent eafee76a93
commit 468f5b2d29
15 changed files with 715 additions and 217 deletions

View File

@@ -13,6 +13,7 @@ import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { BrandFooter } from '@/components/BrandFooter';
import { BrandMark } from '@/components/BrandMark';
import { StreakFlame, flameState } from '@/components/StreakFlame';
/**
* خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با
@@ -38,11 +39,24 @@ export function HomeScreen() {
}, [home]);
const name = profile.name || 'دوستِ من';
const flame = flameState(summary?.streak);
const streak = summary?.streak;
// زنجیره‌ی در خطر یا یخ‌زده جلوتر از پیامِ معمول می‌آید: از دست دادن انگیزه‌ی قوی‌تری از به‌دست آوردن است،
// ولی لحن مهربان می‌ماند و قدمِ بعدی را می‌گوید.
const streakNote = cont && streak
? flame === 'frozen'
? <>{name}، زنجیره‌ی {toFa(streak.lost)} روزه‌ت یخ زد <StreakFlame state="frozen" size={14} className="mb-0.5 inline" /> یه جزیره بزن تا دوباره آتیشش روشن بشه!</>
: flame === 'risk'
? <>{name}، امروز هنوز تمرین نکردی! زنجیره‌ی {toFa(streak.current)} روزه‌ت منتظرته <StreakFlame state="lit" size={14} className="mb-0.5 inline" /></>
: null
: null;
const bubble = !home
? 'دارم مسیرت رو میارم…'
: !home.units.length
? 'هنوز درسی برای این پایه ثبت نشده.'
: cont
: streakNote
? streakNote
: cont
? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیره‌ی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمه‌ی <b>شروع</b> رو بزن.</>
: <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
@@ -69,10 +83,10 @@ export function HomeScreen() {
type="button"
aria-label={`${label}: ${toFa(value)}`}
onClick={() => nav.push({ name: to })}
className="hit flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm active:scale-95"
className={`hit flex items-center gap-1 rounded-full px-2.5 py-1.5 text-xs font-black shadow-sm active:scale-95 ${to === 'streak' && flame === 'frozen' ? 'bg-[#E6F6FF] text-[#2B7FB8]' : 'bg-white text-ink'}`}
>
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />
{toFa(value)}
{to === 'streak' ? <StreakFlame state={flame} size={13} /> : <Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />}
{toFa(to === 'streak' && flame === 'frozen' ? streak?.lost ?? 0 : value)}
</button>
))}
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95">

View File

@@ -10,6 +10,7 @@ import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHead
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { Diagram } from '@/diagram';
import { StepText } from '@/lib/StepText';
/** معرفیِ درس، پیش از تمرین. */
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
@@ -51,7 +52,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
<div className="flex items-start gap-3">
<SeyarMascot char="sigma" mood="idle" size={56} />
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
<p className="whitespace-pre-line text-sm leading-[1.95] text-body"><MathText text={data.introText} /></p>
<StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" />
{figs('intro')}
</div>
</div>
@@ -63,7 +64,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
{data.example && (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink"><MathText text={data.example} /></p>
<StepText text={data.example} color={color} className="text-[15px] leading-[1.95] text-ink" />
{figs('example')}
</section>
)}

View File

@@ -17,6 +17,7 @@ import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { sounds } from '@/lib/sound';
import { useGateHold } from '@/lib/useAsync';
import { StreakCelebration } from '@/components/StreakFlame';
type Phase =
| { kind: 'loading' }
@@ -47,7 +48,10 @@ type PlayQuestion = Question & { lessonId?: string };
* اصلیِ بازی همین است.
*/
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
const { home, companion, refreshHome, refreshSummary } = useAppData();
const { home, companion, summary, refreshHome, refreshSummary } = useAppData();
// زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحه‌ی «روزِ پیاپی» نشان داده می‌شود.
const streakBefore = useRef(summary?.streak ?? null);
const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null);
const nav = useNav();
const toast = useToast();
@@ -114,6 +118,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
/** انتخاب فقط گزینه را آبی می‌کند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمی‌شود و می‌شود عوضش کرد. */
function choose(optionId: string) {
if (!question || answer || submitting) return;
if (optionId !== selected) sounds.select();
setSelected(optionId);
}
@@ -185,6 +190,14 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }));
void refreshHome();
void refreshSummary();
const before = streakBefore.current;
if (before && !before.activeToday) {
api.summary()
.then((fresh) => {
if (fresh.streak.activeToday) setStreakUp({ from: before.current, to: fresh.streak.current });
})
.catch((err: unknown) => console.warn('streak check after finish failed', err));
}
} catch (error) {
// سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار.
if (error instanceof ApiError && error.code === 'no_hearts') {
@@ -223,7 +236,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
if (phase.kind === 'noHearts') {
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
}
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />;
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} streakUp={streakUp} />;
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
return (
<Screen>
@@ -301,10 +314,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
}`}>
{bubble}
{answer && <Reaction key={question.id} correct={answer.correct} seed={index} />}
{answer && <Reaction key={`r${question.id}`} correct={answer.correct} seed={index} />}
{answer?.correct && answer.xpEarned > 0 && (
<motion.span
key={question.id}
key={`xp${question.id}`}
initial={{ opacity: 0, y: 8, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
@@ -413,11 +426,15 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
}
function Result({
result, total, correct, lessonId, nodeId, color,
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
result, total, correct, lessonId, nodeId, color, streakUp,
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
const nav = useNav();
const { companion } = useAppData();
const [loadingNext, setLoadingNext] = useState(false);
// بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحه‌ی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخاب‌شده.
const [streakThen, setStreakThen] = useState<(() => void) | null>(null);
const then = (go: () => void) => (streakUp ? setStreakThen(() => go) : go());
if (streakThen && streakUp) return <StreakCelebration from={streakUp.from} to={streakUp.to} onDone={streakThen} />;
const nodeMode = lessonId === undefined;
const nextNode = result.nextNodeId ?? null;
@@ -479,11 +496,11 @@ function Result({
<span className="text-[10px] text-muted">سکه</span>
</div>
</div>}
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
{nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton>
{nodeMode && nextNode && (
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
<button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
فعلاً بسه، برگردیم
</button>
)}

View File

@@ -3,6 +3,8 @@ import { api } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
import { useAppData } from '@/state/AppData';
import { StreakFlame, flameState } from '@/components/StreakFlame';
const WEEKS = 6;
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
@@ -30,6 +32,8 @@ const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
export function StreakScreen() {
const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' });
const { summary } = useAppData();
const flame = flameState(summary?.streak);
const active = new Set(calendar.data?.activeDays ?? []);
const today = isoDay(new Date());
const weeks = recentWeeks();
@@ -48,16 +52,16 @@ export function StreakScreen() {
{[
{ label: 'زنجیره‌ی فعلی', value: calendar.data.currentStreak },
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
].map((s) => (
].map((s, i) => (
<div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4">
<Flame size={24} className="text-orange" fill="#FF7A3D" />
{i === 0 ? <StreakFlame state={flame} size={26} /> : <Flame size={24} className="text-orange" fill="#FF7A3D" />}
<span className="mt-1 text-2xl font-black text-ink">{toFa(s.value)}</span>
<span className="text-xs text-muted">{s.label} (روز)</span>
</div>
))}
</div>
<StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} />
<StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} lost={summary?.streak.lost ?? 0} />
<div className="rounded-3xl border border-line bg-white p-4">
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
@@ -96,12 +100,14 @@ export function StreakScreen() {
/** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */
const MILESTONES = [3, 7, 14, 30, 60, 100];
function StreakGoal({ current, doneToday }: { current: number; doneToday: boolean }) {
function StreakGoal({ current, doneToday, lost }: { current: number; doneToday: boolean; lost: number }) {
const next = MILESTONES.find((m) => m > current);
const reached = MILESTONES.filter((m) => m <= current).pop();
// زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود.
// زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد.
const today = current === 0
const today = current === 0 && lost > 0
? `زنجیره‌ی ${toFa(lost)} روزه‌ت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
: current === 0
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.'
: !doneToday
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره‌ی ${toFa(current)} روزه‌ت نسوزه.`

View File

@@ -1,14 +1,15 @@
import { useState, type ReactNode } from 'react';
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
import { useEffect, useState, type ReactNode } from 'react';
import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
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 { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { useBackStep } from '@/nav/Navigator';
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { formatJalaliDate, formatToman, toFa } from '@/lib/format';
import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format';
function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یک‌ماهه';
@@ -18,208 +19,241 @@ function durationLabel(days: number): string {
return days ? `${toFa(days)} روزه` : '';
}
/** «۷۹۸ هزار تومان» به‌جای «۷۹۸٬۰۰۰ تومان»؛ صفرهای پشتِ هم برای بچه خواندنِ قیمت را سخت می‌کرد. */
function price(value: number): string {
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
}
type View = 'subscription' | 'pack';
/**
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/شش‌ماهه؛ همه‌ی فصل‌ها، قلبِ نامحدود، سقفِ روزانه‌ی دستیار)
* و «بسته‌ی سوال» (سوالِ بی‌انقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
* یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`).
*
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیج‌کننده»): مزیت‌های اشتراک یک بار بالای فهرست
* می‌آیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
* انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگه‌ی پرداخت انتخاب می‌شود.
*/
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
const quota = useAsync(() => api.ai.quota(), [], { key: 'ai-quota' });
const [view, setView] = useState<View | null>(initialView ?? null);
const [picked, setPicked] = useState<string | null>(null);
const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ محصولی که
// SKU ندارد آن‌جا خریدنی نیست (به زرین‌پال فرستاده نمی‌شود).
const bazaar = inBazaarApp();
// از فهرست، «برگشت» اول به دو گزینه برمی‌گردد.
useBackStep(view !== null && !initialView, () => setView(null));
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
const reload = () => { status.reload(); quota.reload(); };
const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
// پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز می‌شود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
useEffect(() => {
const onVisible = () => document.visibilityState === 'visible' && status.reload();
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [status.reload]);
const all = plans.data?.plans ?? [];
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];
const selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0];
const active = status.data?.active ?? false;
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<ScreenHeader
title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همه‌ی فصل‌ها، قلبِ نامحدود'}
title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? (active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو') : 'فروشگاه'}
subtitle={view === 'pack' ? 'سوالِ بیشتر از پی، بدونِ تاریخِ انقضا' : 'همه‌ی فصل‌ها، قلبِ نامحدود'}
color="#ED6956"
exact
back={view !== null && !initialView ? () => setView(null) : true}
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
/>
<div className="flex flex-col gap-4 px-4 py-5">
<StatusCard status={status.data ?? null} statusLoading={status.loading && !status.data} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} quotaLoading={quota.loading && !quota.data} />
{view === null && <StatusCard status={status.data ?? null} loading={status.loading && !status.data} />}
{view === null ? (
<div className="flex flex-col gap-3">
<ChoiceCard
icon={<Crown size={24} />}
icon={<Crown size={26} />}
tone="#FF7A3D"
title="اشتراکِ ماهانه"
text="همه‌ی فصل‌ها و جزیره‌ها باز، با قلبِ نامحدود"
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
title={active ? 'تمدیدِ اشتراک' : 'اشتراک'}
text="همه‌ی فصل‌ها باز، با قلبِ نامحدود"
hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined}
onClick={() => setView('subscription')}
/>
<ChoiceCard
icon={<Sparkles size={24} />}
icon={<Sparkles size={26} />}
tone="#9B7BFF"
title="بسته‌ی سوال"
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچ‌وقت منقضی نمی‌شه"
text="سوالِ بیشتر از پی، هیچ‌وقت تموم نمی‌شه"
onClick={() => setView('pack')}
/>
</div>
) : plans.loading && !plans.data ? (
<LoadingState text="دارم فهرست رو میارم…" />
) : plans.error && !plans.data ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !list.length ? (
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
) : (
<>
{!initialView && (
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
<ChevronRight size={14} /> برگشت به انتخاب
</button>
)}
{plans.loading ? (
<LoadingState text="دارم فهرست رو میارم…" />
) : plans.error ? (
<ErrorState error={plans.error} onRetry={plans.reload} />
) : !list.length ? (
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
{view === 'subscription' ? (
<Perks items={selected?.features.length ? selected.features : ['همه‌ی فصل‌ها و جزیره‌ها باز', 'قلبِ نامحدود']} />
) : (
list.map((plan) => (
<PackCard
key={plan.id}
plan={plan}
monthlyPrice={monthly?.price}
// اشتراک در بازار هنوز SKU و تمدیدِ آزموده‌شده ندارد؛ آن‌جا فقط بسته خریدنی است.
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
onBuy={() => setBuying(plan)}
/>
))
)}
{view === 'pack' && (
<p className="text-center text-xs leading-6 text-muted">
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانه‌ته؛ بعدش از اعتبارِ بسته کم می‌شه.
<p className="px-1 text-center text-sm leading-7 text-body">
هر روز اول از سوال‌های رایگانِ همون روز خرج می‌شه، بعد از بسته.
</p>
)}
<div role="radiogroup" aria-label={view === 'pack' ? 'بسته‌ها' : 'مدتِ اشتراک'} className="flex flex-col gap-3">
{list.map((plan) => (
<PlanRow key={plan.id} plan={plan} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
))}
</div>
</>
)}
<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(); }} />
{view !== null && selected && (
<div className="flex-shrink-0 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
<PrimaryButton color={view === 'pack' ? '#9B7BFF' : '#FF7A3D'} onClick={() => setBuying(selected)}>
{view === 'pack' ? `خریدِ ${toFa(selected.questionCredits)} سوال` : `خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
</PrimaryButton>
</div>
)}
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
</Screen>
);
}
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className="flex items-center gap-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]">
<button
type="button"
onClick={onClick}
className="flex items-center gap-4 rounded-3xl border-2 border-b-4 bg-white p-4 text-right transition-transform active:translate-y-0.5"
style={{ borderColor: `${tone}40` }}
>
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-base font-black text-ink">{title}</span>
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
<span className="block text-lg font-black text-ink">{title}</span>
<span className="mt-0.5 block text-sm leading-6 text-body">{text}</span>
{hint && <span className="mt-1 block text-sm font-black" style={{ color: tone }}>{hint}</span>}
</span>
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
<ChevronLeft size={20} className="flex-shrink-0" style={{ color: tone }} />
</button>
);
}
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
/** تا وضعیت نرسیده جای خالیِ هم‌اندازه نشان داده می‌شود؛ قبلاً «نداری» می‌آمد و برای مشترک غلط بود. */
const Placeholder = ({ className }: { className: string }) => <span className={`inline-block animate-pulse rounded-lg bg-soft align-middle ${className}`} />;
function StatusCard({ status, statusLoading, credits, heartsUntil, quota, quotaLoading }: {
status: SubscriptionStatus | null; statusLoading: boolean; credits: number; heartsUntil: string | null; quota: TutorQuota | null; quotaLoading: boolean;
}) {
const days = status?.active ? daysLeft(status.expiresAt) : null;
/**
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بی‌اشتراک: هیچ کارتی (دو گزینه‌ی زیر
* خودشان حرف را می‌زنند)، مگر سوالِ خریده‌شده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هم‌اندازه
* می‌آید، نه «نداری» که برای مشترک غلط بود.
*/
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;
}
const total = status.planDurationDays ?? 30;
const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)));
return (
<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 ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span>
<div className="flex-1">
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
<div className="text-base font-black text-ink">
{statusLoading ? <Placeholder className="h-5 w-20" /> : status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
<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>
{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>
{quotaLoading && <Placeholder className="h-10 w-14" />}
{quota && (
<div className="text-left text-xs leading-6 text-muted">
سوالِ امروز<br />
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {statusLoading && quotaLoading ? <Placeholder className="h-3.5 w-10" /> : <>{toFa(credits)} سوال</>}</span>
{(status?.active || heartsUntil) && (
<span className="flex items-center gap-1.5 text-coral">
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
</span>
)}
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
</div>
{(status.questionCredits > 0) && <div className="flex flex-wrap gap-2">{extras}</div>}
</div>
);
}
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) {
const months = Math.round(plan.durationDays / 30);
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const featured = plan.popular;
const isPack = plan.kind === 'pack';
const tone = featured ? 'text-white/80' : 'text-muted';
function Pill({ icon, text }: { icon: ReactNode; text: string }) {
return (
<div className="relative pt-3">
<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 Perks({ items }: { items: string[] }) {
return (
<ul className="flex flex-col gap-2.5 rounded-3xl bg-[#FFF4EC] p-4">
{items.map((f) => (
<li key={f} className="flex items-center gap-2.5 text-[15px] font-bold text-ink">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white"><Check size={14} strokeWidth={3.5} /></span>
{f}
</li>
))}
</ul>
);
}
/** یک ردیفِ انتخابی: نام و قیمت درشت؛ فقط یک نکته‌ی کوچک (تخفیف یا هدیه)، نه پنج خطِ ریز. */
function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
const isPack = plan.kind === 'pack';
const months = Math.round(plan.durationDays / 30);
const saving = !isPack && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const tone = isPack ? '#9B7BFF' : '#FF7A3D';
const note = isPack
? plan.heartsDays > 0 ? `+ ${toFa(plan.heartsDays)} روز قلبِ نامحدود هدیه` : ''
: saving > 0 ? `${toFa(saving)}٪ ارزان‌تر از ماهانه` : '';
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className="relative flex items-center gap-3 rounded-3xl border-2 border-b-4 bg-white px-4 py-4 text-right transition-colors active:translate-y-0.5"
style={{ borderColor: selected ? tone : '#e9e6f7', backgroundColor: selected ? `${tone}0F` : '#fff' }}
>
{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>
<span className="absolute -top-3 left-4 rounded-full px-3 py-0.5 text-xs font-black text-white shadow-sm" style={{ backgroundColor: tone }}>
{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>
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزان‌تر</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>
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
style={{ borderColor: selected ? tone : '#d6d3ea', backgroundColor: selected ? tone : '#fff' }}
>
{selected && <Check size={14} strokeWidth={3.5} className="text-white" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-black text-ink">
{isPack ? <>{toFa(plan.questionCredits)} سوال</> : durationLabel(plan.durationDays) || plan.title}
</span>
{note && <span className="mt-0.5 block text-sm font-bold" style={{ color: isPack ? '#7857E0' : '#2E9E6A' }}>{note}</span>}
</span>
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
</button>
);
}
@@ -229,11 +263,16 @@ function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthl
* 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');
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);
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
/**
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
@@ -272,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
if (!plan || busy) return;
setBusy(true);
setError('');
if (bazaar) {
if (canBazaar && method === 'bazaar') {
try {
if (await payWithBazaar(plan)) setDone('bazaar');
} catch (err) {
@@ -295,6 +334,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)');
window.location.assign(order.paymentUrl);
// داخلِ اپ آدرسِ درگاه در مرورگرِ گوشی باز می‌شود و این صفحه همین‌جا می‌ماند؛ دکمه نباید تا ابد بچرخد.
if (bazaar) setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
} catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
setBusy(false);
@@ -308,26 +349,36 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
setShowCode(false);
setError('');
setDone(null);
setMethod('gateway');
};
return (
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}>
<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 === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
{done === 'gateway'
? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.'
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
</p>
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</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 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>
</div>
{bazaar ? null : showCode ? (
{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>
)}
{method === 'gateway' && (showCode ? (
<label className="mb-4 block">
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
<span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span>
<input
value={code}
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
@@ -336,17 +387,42 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
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>
{method === 'gateway' && (
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<ShieldCheck size={14} /> {bazaar ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز می‌شه؛ بعدش برگرد به اپ.' : 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p>
)}
</>
)}
</Sheet>
);
}
function MethodRow({ selected, onSelect, icon, title, note }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string }) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
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'}`}
>
<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>
</button>
);
}