Store sells subscriptions only: each card lists what you get (features + assistant questions/day), tab renamed, assistant points to subscription
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصولهای ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصولهای ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
|
||||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
|
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
|
||||||
| SKUِ هر پلن | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | یکماهه `10342`، سهماهه `10343`، ششماهه `10344`؛ بستهها هنوز در پیشخان تعریف نشدهاند |
|
| SKUِ هر پلن | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | یکماهه `10342`، سهماهه `10343`، ششماهه `10344`؛ بستهی سوال فروخته نمیشود |
|
||||||
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
|
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
|
||||||
|
|
||||||
قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson, productType)` را صدا میزند و قیمتِ پیشخان را روی کاشیِ بازار نشان میدهد.
|
قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson, productType)` را صدا میزند و قیمتِ پیشخان را روی کاشیِ بازار نشان میدهد.
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSig
|
|||||||
case 'home': return <HomeScreen />;
|
case 'home': return <HomeScreen />;
|
||||||
case 'lessons': return <LessonsScreen />;
|
case 'lessons': return <LessonsScreen />;
|
||||||
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
||||||
case 'subscription': return <SubscriptionScreen key={route.view ?? ''} initialView={route.view} />;
|
case 'subscription': return <SubscriptionScreen />;
|
||||||
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
||||||
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
||||||
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
|||||||
{ id: 'home', icon: Home, label: 'خانه' },
|
{ id: 'home', icon: Home, label: 'خانه' },
|
||||||
{ id: 'lessons', icon: BookOpen, label: 'درسها' },
|
{ id: 'lessons', icon: BookOpen, label: 'درسها' },
|
||||||
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
||||||
{ id: 'subscription', icon: ShoppingBag, label: 'بستهها' },
|
{ id: 'subscription', icon: ShoppingBag, label: 'اشتراک' },
|
||||||
{ id: 'profile', icon: UserRound, label: 'من' },
|
{ id: 'profile', icon: UserRound, label: 'من' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type Route =
|
|||||||
| { name: 'lessons' }
|
| { name: 'lessons' }
|
||||||
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
||||||
/** view: مستقیم به فهرستِ اشتراکها یا بستهها (مثلاً از فصلِ قفل)؛ بیآن انتخابِ دوگزینهای. */
|
/** view: مستقیم به فهرستِ اشتراکها یا بستهها (مثلاً از فصلِ قفل)؛ بیآن انتخابِ دوگزینهای. */
|
||||||
| { name: 'subscription'; view?: 'subscription' | 'pack' }
|
| { name: 'subscription' }
|
||||||
| { name: 'profile' }
|
| { name: 'profile' }
|
||||||
| { name: 'unit'; unitId: string }
|
| { name: 'unit'; unitId: string }
|
||||||
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ function Message({
|
|||||||
<div className="flex flex-wrap items-center gap-2 pr-10">
|
<div className="flex flex-wrap items-center gap-2 pr-10">
|
||||||
{message.quotaReached ? (
|
{message.quotaReached ? (
|
||||||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
|
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
|
||||||
خریدِ بستهی سوال
|
اشتراک
|
||||||
</button>
|
</button>
|
||||||
) : message.failedQuestion ? (
|
) : message.failedQuestion ? (
|
||||||
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
|
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
|
||||||
@@ -355,23 +355,23 @@ function Message({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* شمارنده در سربرگِ دستیار: سوالهای رایگانِ باقیِ امروز و سوالهای خریدهشده، هر کدام جدا و
|
* شمارنده در سربرگِ دستیار: سوالهای رایگانِ باقیِ امروز، و اگر از بستههای قدیمی سوالی خریده و مانده، آن هم جدا.
|
||||||
* درشت، تا بچه پیش از پرسیدن بداند چقدر دارد. زدن روی دومی صفحهی بستهها را باز میکند.
|
* بسته از ۲۰۲۶-۱۰-۱۲ فروخته نمیشود؛ وقتی رایگانِ امروز تمام شد، دکمهی دوم به فروشگاهِ اشتراک میبرد.
|
||||||
*/
|
*/
|
||||||
function QuotaPills({ free, credits }: { free: number; credits: number }) {
|
function QuotaPills({ free, credits }: { free: number; credits: number }) {
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
return (
|
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'}`}>
|
<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)} سوالِ رایگانِ امروز</> : 'رایگانِ امروز تموم شد'}
|
{free > 0 ? <>{toFa(free)} سوالِ امروز</> : 'سوالهای امروز تموم شد'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{credits > 0 ? (
|
||||||
type="button"
|
<span className="rounded-full bg-white px-3 py-1.5 text-xs font-black text-violet">{toFa(credits)} سوالِ خریدهشده</span>
|
||||||
onClick={() => nav.switchTab('subscription')}
|
) : free === 0 ? (
|
||||||
className={`rounded-full px-3 py-1.5 text-xs font-black ${credits > 0 ? 'bg-white text-violet' : 'bg-white/15 text-white'}`}
|
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-white/15 px-3 py-1.5 text-xs font-black text-white">
|
||||||
>
|
سوالِ بیشتر با اشتراک
|
||||||
{credits > 0 ? <>{toFa(credits)} سوالِ خریدهشده</> : '+ خریدِ سوال'}
|
</button>
|
||||||
</button>
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function HomeScreen() {
|
|||||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||||||
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
const paywall = () => nav.push({ name: 'subscription' });
|
||||||
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
||||||
const currentRef = useRef<HTMLDivElement>(null);
|
const currentRef = useRef<HTMLDivElement>(null);
|
||||||
const scrolled = useRef(false);
|
const scrolled = useRef(false);
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function UnitCard({
|
|||||||
className="overflow-hidden rounded-2xl bg-white"
|
className="overflow-hidden rounded-2xl bg-white"
|
||||||
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
|
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
|
||||||
>
|
>
|
||||||
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription', view: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
||||||
<span
|
<span
|
||||||
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
|
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
|
||||||
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
|
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
// فصلِ غیرِ اول بیاشتراک (۴۰۲)؛ صفحهی خطا معنا ندارد، کاربر یکراست به اشتراکها میرود.
|
// فصلِ غیرِ اول بیاشتراک (۴۰۲)؛ صفحهی خطا معنا ندارد، کاربر یکراست به اشتراکها میرود.
|
||||||
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription', view: 'subscription' });
|
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription' });
|
||||||
else setPhase({ kind: 'error', error });
|
else setPhase({ kind: 'error', error });
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
|
import { Check, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
|
||||||
import { api, type Plan, type SubscriptionStatus } from '@/api';
|
import { api, type Plan, type SubscriptionStatus } from '@/api';
|
||||||
import { daysLeft } from '@/lib/subscription';
|
import { daysLeft } from '@/lib/subscription';
|
||||||
import { newIdempotencyKey } from '@/api/http';
|
import { newIdempotencyKey } from '@/api/http';
|
||||||
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
|
import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, bazaarPrices, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar';
|
||||||
import { useAsync } from '@/lib/useAsync';
|
import { useAsync } from '@/lib/useAsync';
|
||||||
import { Sheet } from '@/components/Sheet';
|
import { Sheet } from '@/components/Sheet';
|
||||||
import { useBackStep } from '@/nav/Navigator';
|
|
||||||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format';
|
import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format';
|
||||||
@@ -25,28 +24,19 @@ function price(value: number): string {
|
|||||||
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
|
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
type View = 'subscription' | 'pack';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستیار)
|
* فروشگاه فقط اشتراک دارد (یک/سه/ششماهه؛ تصمیمِ سینیور ۲۰۲۶-۱۰-۱۲: بستهی سوال دیگر فروخته نمیشود). هر کارت
|
||||||
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
|
* خودش میگوید چه میگیری (خواستِ سینیور): مزیتهای پلن (`features`، از ادمین) و سقفِ روزانهی دستیار از `aiDailyQuota`،
|
||||||
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
* تا عدد با سقفِ واقعی جدا نیفتد. یک دکمهی بزرگ پایینِ صفحه؛ روشِ پرداخت (زرینپال یا کافهبازار) در برگهی پرداخت.
|
||||||
*
|
|
||||||
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیجکننده»): مزیتهای اشتراک یک بار بالای فهرست
|
|
||||||
* میآیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
|
|
||||||
* انتخاب است با نام و قیمت، و فقط یک دکمهی بزرگ پایینِ صفحه هست. روشِ پرداخت (زرینپال یا کافهبازار) در برگهی پرداخت انتخاب میشود.
|
|
||||||
*/
|
*/
|
||||||
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
export function SubscriptionScreen() {
|
||||||
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
|
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
|
||||||
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
|
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
|
||||||
const [view, setView] = useState<View | null>(initialView ?? null);
|
|
||||||
const [picked, setPicked] = useState<string | null>(null);
|
const [picked, setPicked] = useState<string | null>(null);
|
||||||
const [buying, setBuying] = useState<Plan | null>(null);
|
const [buying, setBuying] = useState<Plan | null>(null);
|
||||||
// داخلِ اپی که از بازار میخرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار میگیرد.
|
// داخلِ اپی که از بازار میخرد: قیمتِ پیشخانِ هر SKU، تا کاشیِ بازار همان را بگوید که بازار میگیرد.
|
||||||
const [storePrices, setStorePrices] = useState<Record<string, string>>({});
|
const [storePrices, setStorePrices] = useState<Record<string, string>>({});
|
||||||
const look = useCompanion();
|
const look = useCompanion();
|
||||||
// از فهرست، «برگشت» اول به دو گزینه برمیگردد.
|
|
||||||
useBackStep(view !== null && !initialView, () => setView(null));
|
|
||||||
|
|
||||||
// پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز میشود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
|
// پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز میشود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,19 +45,16 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
|||||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||||
}, [status.reload]);
|
}, [status.reload]);
|
||||||
|
|
||||||
const all = plans.data?.plans ?? [];
|
const list = (plans.data?.plans ?? []).filter((p) => p.kind === 'subscription');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canBuyFromBazaar() || !plans.data) return;
|
const skus = list.filter((p) => p.bazaarSku).map((p) => p.bazaarSku);
|
||||||
const skus = (kind: Plan['kind']) => plans.data!.plans.filter((p) => p.kind === kind && p.bazaarSku).map((p) => p.bazaarSku);
|
if (!canBuyFromBazaar() || !skus.length) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
void Promise.all([bazaarPrices(skus('subscription'), 'subscription'), bazaarPrices(skus('pack'), 'inapp')]).then(
|
void bazaarPrices(skus, 'subscription').then((prices) => alive && setStorePrices(prices));
|
||||||
([subs, packs]) => alive && setStorePrices({ ...subs, ...packs }),
|
|
||||||
);
|
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [plans.data]);
|
}, [plans.data]);
|
||||||
const list = all.filter((p) => p.kind === view);
|
const monthly = list.find((p) => p.durationDays <= 31);
|
||||||
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 selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0];
|
||||||
const active = status.data?.active ?? false;
|
const active = status.data?.active ?? false;
|
||||||
|
|
||||||
@@ -75,64 +62,36 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title={view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? (active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو') : 'فروشگاه'}
|
title={active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو'}
|
||||||
subtitle={view === 'pack' ? `سوالِ بیشتر از ${look.name}، بدونِ تاریخِ انقضا` : 'همهی فصلها، قلبِ نامحدود'}
|
subtitle="همهی فصلها، قلبِ نامحدود"
|
||||||
color="#ED6956"
|
color="#ED6956"
|
||||||
exact
|
exact
|
||||||
back={view !== null && !initialView ? () => setView(null) : true}
|
back
|
||||||
mascot={{ mood: 'excited', size: 60 }}
|
mascot={{ mood: 'excited', size: 60 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
{view === null && <StatusCard status={status.data ?? null} loading={status.loading && !status.data} />}
|
<StatusCard status={status.data ?? null} loading={status.loading && !status.data} />
|
||||||
|
{plans.loading && !plans.data ? (
|
||||||
{view === null ? (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<ChoiceCard
|
|
||||||
icon={<Crown size={26} />}
|
|
||||||
tone="#FF7A3D"
|
|
||||||
title={active ? 'تمدیدِ اشتراک' : 'اشتراک'}
|
|
||||||
text="همهی فصلها باز، با قلبِ نامحدود"
|
|
||||||
hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined}
|
|
||||||
onClick={() => setView('subscription')}
|
|
||||||
/>
|
|
||||||
<ChoiceCard
|
|
||||||
icon={<Sparkles size={26} />}
|
|
||||||
tone="#9B7BFF"
|
|
||||||
title="بستهی سوال"
|
|
||||||
text={`سوالِ بیشتر از ${look.name}، هیچوقت تموم نمیشه`}
|
|
||||||
onClick={() => setView('pack')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : plans.loading && !plans.data ? (
|
|
||||||
<LoadingState text="دارم فهرست رو میارم…" />
|
<LoadingState text="دارم فهرست رو میارم…" />
|
||||||
) : plans.error && !plans.data ? (
|
) : plans.error && !plans.data ? (
|
||||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||||
) : !list.length ? (
|
) : !list.length ? (
|
||||||
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div role="radiogroup" aria-label="مدتِ اشتراک" className="flex flex-col gap-3">
|
||||||
{view === 'subscription' ? (
|
{list.map((plan) => (
|
||||||
<Perks items={selected?.features.length ? selected.features : ['همهی فصلها و جزیرهها باز', 'قلبِ نامحدود']} />
|
<PlanRow key={plan.id} plan={plan} assistant={look.name} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
|
||||||
) : (
|
))}
|
||||||
<p className="px-1 text-center text-sm leading-7 text-body">
|
</div>
|
||||||
هر روز اول از سوالهای رایگانِ همون روز خرج میشه، بعد از بسته.
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view !== null && selected && (
|
{selected && (
|
||||||
<div className="flex-shrink-0 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
|
<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)}>
|
<PrimaryButton color="#FF7A3D" onClick={() => setBuying(selected)}>
|
||||||
{view === 'pack' ? `خریدِ ${toFa(selected.questionCredits)} سوال` : `خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
|
{`خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -142,25 +101,6 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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-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-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={20} className="flex-shrink-0" style={{ color: tone }} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بیاشتراک: هیچ کارتی (دو گزینهی زیر
|
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بیاشتراک: هیچ کارتی (دو گزینهی زیر
|
||||||
* خودشان حرف را میزنند)، مگر سوالِ خریدهشده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هماندازه
|
* خودشان حرف را میزنند)، مگر سوالِ خریدهشده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هماندازه
|
||||||
@@ -215,36 +155,19 @@ function Pill({ icon, text }: { icon: ReactNode; text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** مزیتهای اشتراک، یک بار. */
|
/** یک کارتِ انتخابی: نام و قیمت درشت، و زیرش آنچه این اشتراک میدهد (خواستِ سینیور: روی هر کارت). */
|
||||||
function Perks({ items }: { items: string[] }) {
|
function PlanRow({ plan, assistant, monthlyPrice, selected, onSelect }: { plan: Plan; assistant: string; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
|
||||||
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 months = Math.round(plan.durationDays / 30);
|
||||||
const saving = !isPack && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
|
const saving = monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
|
||||||
const tone = isPack ? '#9B7BFF' : '#FF7A3D';
|
const tone = '#FF7A3D';
|
||||||
const note = isPack
|
const perks = [...plan.features, `${toFa(plan.aiDailyQuota)} سوال در روز از ${assistant}`];
|
||||||
? plan.heartsDays > 0 ? `+ ${toFa(plan.heartsDays)} روز قلبِ نامحدود هدیه` : ''
|
|
||||||
: saving > 0 ? `${toFa(saving)}٪ ارزانتر از ماهانه` : '';
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
onClick={onSelect}
|
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"
|
className="relative flex flex-col 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' }}
|
style={{ borderColor: selected ? tone : '#e9e6f7', backgroundColor: selected ? `${tone}0F` : '#fff' }}
|
||||||
>
|
>
|
||||||
{plan.badge && (
|
{plan.badge && (
|
||||||
@@ -252,19 +175,27 @@ function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; month
|
|||||||
{plan.badge}
|
{plan.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span className="flex items-center gap-3">
|
||||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
|
<span
|
||||||
style={{ borderColor: selected ? tone : '#d6d3ea', backgroundColor: selected ? tone : '#fff' }}
|
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>
|
{selected && <Check size={14} strokeWidth={3.5} className="text-white" />}
|
||||||
<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>
|
</span>
|
||||||
{note && <span className="mt-0.5 block text-sm font-bold" style={{ color: isPack ? '#7857E0' : '#2E9E6A' }}>{note}</span>}
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-lg font-black text-ink">{durationLabel(plan.durationDays) || plan.title}</span>
|
||||||
|
{saving > 0 && <span className="mt-0.5 block text-sm font-bold text-[#2E9E6A]">{toFa(saving)}٪ ارزانتر از ماهانه</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
|
<ul className="flex flex-col gap-1.5 border-t border-line pt-3">
|
||||||
|
{perks.map((f) => (
|
||||||
|
<li key={f} className="flex items-center gap-2 text-sm font-bold text-body">
|
||||||
|
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white"><Check size={12} strokeWidth={3.5} /></span>
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -411,7 +342,7 @@ function CheckoutSheet({ plan, storePrices, onClose, onPaid }: { plan: Plan | nu
|
|||||||
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
|
: 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? (plan.kind === 'pack' ? `${toFa(plan.questionCredits)} سوال` : `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}`) : ''}>
|
<Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}` : ''}>
|
||||||
{done ? (
|
{done ? (
|
||||||
<>
|
<>
|
||||||
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
|
<p className="mb-5 text-sm leading-7 text-muted">{doneText}</p>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { MathText } from '@/lib/MathText';
|
|||||||
export function UnitScreen({ unitId }: { unitId: string }) {
|
export function UnitScreen({ unitId }: { unitId: string }) {
|
||||||
const { home, homeError, refreshHome, companion } = useAppData();
|
const { home, homeError, refreshHome, companion } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
const paywall = () => nav.push({ name: 'subscription' });
|
||||||
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
||||||
const found = findUnit(home, unitId);
|
const found = findUnit(home, unitId);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user