rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused packages, 45 unused UI components, no type-check). The live site could not reach the backend, Persian-keyboard digits were erased from the phone field, guests were signed out after an hour, "mark read" hit a method the server does not accept (405), and iOS Safari blocked the payment window. What changed: - src/ split into api/ (one typed client: timeouts, GET retry, single-flight refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/. - The API address is configuration only: public/config.js at runtime, then VITE_API_BASE_URL at build time, then same-origin /api. No domain in code. - Responsive shell instead of a fixed 390x844 phone frame; self-hosted Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build` type-checks first. - Everything on the handoff list: tutor answers keep their line breaks, have thumbs up/down, follow-ups, "from the book" badge, retry, history that survives tab switches and reloads, "why was I wrong" from a wrong answer with questionId; question reports; hearts with maxHearts/unlimited and a no-hearts screen with the refill time; result screen and a visible error when lesson completion fails; subscription status, badge, discount code before the gateway and the payment-return message; guest refresh token; 6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH read-all; leaderboard refetched on open with "you" highlighted; selected companion shown across the app; logout that revokes the refresh token. - New screens for endpoints that had none: profile, streak calendar, support tickets and bug reports, parents, invite-a-friend (with ?ref= tracking), guest-to-phone upgrade. - Browser/phone back button walks the screen stack. Checked with a headless-Chrome walkthrough against a local backend at 390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map, island sheet, every tab and sub-screen, one real tutor question, reload, logout): no console errors, no failed API calls. Real SMS login and a real Zarinpal payment were not exercised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
192
src/screens/SubscriptionScreen.tsx
Normal file
192
src/screens/SubscriptionScreen.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Crown, Heart, Ticket } from 'lucide-react';
|
||||
import { api, type Plan } from '@/api';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { formatJalaliDate, formatToman, toFa } from '@/lib/format';
|
||||
|
||||
function durationLabel(days: number): string {
|
||||
if (days === 30 || days === 31) return 'یکماهه';
|
||||
if (days >= 360 && days <= 370) return 'یکساله';
|
||||
if (days === 90) return 'سهماهه';
|
||||
return days ? `${toFa(days)} روزه` : '';
|
||||
}
|
||||
|
||||
export function SubscriptionScreen() {
|
||||
const plans = useAsync(() => api.subscription.plans(), []);
|
||||
const status = useAsync(() => api.subscription.status(), []);
|
||||
const [buying, setBuying] = useState<Plan | null>(null);
|
||||
|
||||
const activePlanId = status.data?.active ? status.data.planId : null;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScreenHeader title="اشتراکِ ویژه" subtitle="یادگیری بدونِ وقفه" color="#ED6956" mascot={{ char: 'sigma', mood: 'excited', size: 60 }} />
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-5">
|
||||
{status.data?.active && (
|
||||
<div className="flex items-center gap-3 rounded-3xl border-2 border-mint bg-[#EAF8F0] p-4">
|
||||
<Crown size={22} className="flex-shrink-0 text-mint" />
|
||||
<div className="text-sm leading-7 text-ink">
|
||||
<b>اشتراکت فعاله.</b>
|
||||
{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}.</>}
|
||||
<div className="text-xs text-muted">اگه دوباره بخری، از روزِ پایانِ همین اشتراک تمدید میشه.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
|
||||
<Heart size={22} className="flex-shrink-0 text-coral" fill="#ED6956" />
|
||||
<p className="text-sm leading-7 text-body">با اشتراک، قلبهات هیچوقت تموم نمیشن و هر چقدر بخوای تمرین میکنی.</p>
|
||||
</div>
|
||||
|
||||
{plans.loading ? (
|
||||
<LoadingState text="دارم پلنها رو میارم…" />
|
||||
) : plans.error ? (
|
||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||
) : !plans.data?.length ? (
|
||||
<EmptyState text="فعلاً پلنی برای خرید تعریف نشده" />
|
||||
) : (
|
||||
plans.data.map((plan) => {
|
||||
const featured = plan.popular;
|
||||
const isCurrent = plan.id === activePlanId;
|
||||
return (
|
||||
<div key={plan.id} className="relative pt-3">
|
||||
{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>
|
||||
)}
|
||||
<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-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-black">{plan.title}</h3>
|
||||
<p className={`mt-0.5 text-xs ${featured ? 'text-white/75' : 'text-muted'}`}>{durationLabel(plan.durationDays)}</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-black">{plan.price === 0 ? 'رایگان' : formatToman(plan.price)}</p>
|
||||
{plan.discountPercent > 0 && (
|
||||
<span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-black ${featured ? 'bg-white/20' : 'bg-[#fde9e5] text-coral'}`}>
|
||||
{toFa(plan.discountPercent)}٪ تخفیف
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{plan.features.length > 0 && (
|
||||
<ul className="mb-4 flex flex-col gap-2">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm">
|
||||
<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"
|
||||
disabled={plan.price === 0 || isCurrent}
|
||||
onClick={() => setBuying(plan)}
|
||||
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-60 ${
|
||||
featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'
|
||||
}`}
|
||||
>
|
||||
{isCurrent ? 'اشتراکِ فعلیِ تو' : plan.price === 0 ? 'رایگان' : status.data?.active ? 'تمدید با این پلن' : 'انتخابِ این پلن'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<p className="pb-2 text-center text-[11px] leading-6 text-faint">پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutSheet plan={buying} onClose={() => setBuying(null)} onPaidWithCode={() => { setBuying(null); status.reload(); }} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* خرید: سفارش ساخته میشود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت میشود،
|
||||
* و بعد کاربر در همین برگه به درگاه میرود. window.open بعد از await را
|
||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
||||
*/
|
||||
function CheckoutSheet({ plan, onClose, onPaidWithCode }: { plan: Plan | null; onClose: () => void; onPaidWithCode: () => void }) {
|
||||
const [code, setCode] = useState('');
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [freeDone, setFreeDone] = useState(false);
|
||||
|
||||
async function pay() {
|
||||
if (!plan || busy) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
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);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.assign(order.paymentUrl);
|
||||
} catch (err) {
|
||||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (freeDone) onPaidWithCode();
|
||||
else onClose();
|
||||
setCode('');
|
||||
setShowCode(false);
|
||||
setError('');
|
||||
setFreeDone(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={plan !== null} onClose={close} title={freeDone ? 'اشتراکت فعال شد! 🎉' : plan?.title}>
|
||||
{freeDone ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">کدِ تخفیفت کلِ مبلغ رو پوشوند و اشتراکت همین الان فعال شد.</p>
|
||||
<PrimaryButton onClick={close}>عالیه!</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>
|
||||
{showCode ? (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
|
||||
dir="ltr"
|
||||
autoCapitalize="characters"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user