From 44871894e015eb6668fc2c21cc15eb46a8b64276 Mon Sep 17 00:00:00 2001 From: AliReza Date: Fri, 2 Oct 2026 06:10:17 +0330 Subject: [PATCH] User-style public ID, tutor quota on plan cards, switch and delete account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile shows "User482917" under the name, no copy button - plan cards start with "N questions a day from Pi (free: M)" taken from the plan itself - "ورود با حسابِ دیگر" signs out cleanly instead of keeping several accounts on one device - "حذفِ حساب": SMS code for phone users, typing "حذف" for guests Co-Authored-By: Claude Opus 5.5 --- src/api/index.ts | 12 ++- src/api/types.ts | 10 +++ src/screens/ProfileScreen.tsx | 137 ++++++++++++++++++++++++----- src/screens/SubscriptionScreen.tsx | 31 +++++-- 4 files changed, 156 insertions(+), 34 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index 6583fba..aed8efc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -46,6 +46,16 @@ export const api = { login: (phone: string, otp: string) => http.post('/auth/login', { phone, otp }).then(saveTokens), + /** + * حذفِ همیشگیِ حساب. کاربرِ شماره‌دار کدِ تازه‌ی پیامکی می‌دهد (اول requestOtp با شماره‌ی خودش)، + * مهمان کلمه‌ی «حذف» را. بعد از موفقیت توکن‌ها پاک می‌شوند؛ دیگر کاربری پشتشان نیست. + */ + deleteAccount: (body: { otp?: string; confirm?: string }) => + http.post<{ success: boolean }>('/user/delete', body).then((r) => { + http.storage.set(null); + return r; + }), + /** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */ async logout() { const refreshToken = http.storage.get()?.refreshToken; @@ -161,7 +171,7 @@ export const api = { }, subscription: { - plans: () => http.get<{ plans: T.Plan[] }>('/subscription/plans').then((r) => r.plans), + plans: () => http.get('/subscription/plans'), status: () => http.get('/subscription/status'), /** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */ purchase: (planId: string) => http.create('/subscription/purchase', { planId }), diff --git a/src/api/types.ts b/src/api/types.ts index 3303cc5..50454b2 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -28,6 +28,8 @@ export interface Grade { export interface UserProfile { userId: string; + /** شناسه‌ی نمایشی «User482917»؛ همان که پشتیبانی در ادمین جستجو می‌کند. */ + publicId: string; phone: string | null; name: string; avatar: string; @@ -245,6 +247,14 @@ export interface Plan { durationDays: number; features: string[]; badge: string; + /** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */ + aiDailyQuota: number; +} + +export interface PlansResponse { + plans: Plan[]; + /** سقفِ روزانه‌ی کاربرِ بی‌اشتراک، برای مقایسه روی کارت. */ + freeAiDailyQuota: number; } export interface SubscriptionStatus { diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index 38a94ec..b464a4b 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from 'react'; import { - BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Copy, Flame, Gift, GraduationCap, LifeBuoy, LogOut, - FileText, Pencil, Smartphone, Sparkles, Star, Trophy, Volume2, VolumeX, + BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut, + FileText, Pencil, Repeat, Smartphone, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX, } from 'lucide-react'; import { api } from '@/api'; import { useAppData } from '@/state/AppData'; @@ -15,9 +15,9 @@ import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDateP import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui'; import { friendlyError } from '@/lib/errors'; import { setSoundEnabled, soundEnabled } from '@/lib/sound'; -import { toFa } from '@/lib/format'; +import { digitsOnly, toFa } from '@/lib/format'; -type Editing = 'name' | 'grade' | 'birth' | 'logout' | null; +type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null; /** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { @@ -25,19 +25,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { const nav = useNav(); const [editing, setEditing] = useState(null); const [sound, setSound] = useState(soundEnabled()); - const toast = useToast(); const age = ageOf(profile.birthDate, profile.birthYear); - // شناسه همان شماره‌ی ثبتِ کاربر است؛ برای پشتیبانی، بی‌آن‌که شماره‌ی تلفن گفته شود. - async function copyId() { - try { - await navigator.clipboard.writeText(profile.userId); - toast('شناسه کپی شد', 'success'); - } catch { - toast(`شناسه‌ی تو: ${toFa(profile.userId)}`, 'success'); - } - } - const go = (route: Route) => () => nav.push(route); return ( @@ -53,9 +42,9 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { - +
+ شناسه‌ی کاربری: {profile.publicId} +
{gradeTitle && {gradeTitle}} {age && {toFa(age)} ساله} @@ -131,8 +120,12 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { /> -
@@ -140,7 +133,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) { setEditing(null)} /> setEditing(null)} /> setEditing(null)} /> - setEditing(null)} onSignedOut={onSignedOut} /> + setEditing(null)} onSignedOut={onSignedOut} /> + setEditing(null)} onSignedOut={onSignedOut} /> ); } @@ -273,7 +267,12 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void ); } -function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) { +/** + * خروج، و «ورود با حسابِ دیگر» که همان خروج است با متنِ دیگر. عمداً چند حسابِ واردشده روی + * یک گوشی نگه داشته نمی‌شود: گوشیِ خانوادگی دست‌به‌دست می‌شود و هر کس بی‌کد واردِ حسابِ + * دیگری می‌شد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد. + */ +function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) { const { profile } = useAppData(); const [busy, setBusy] = useState(false); @@ -289,15 +288,105 @@ function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: ( } return ( - +

+ {switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '} {profile.isGuest ? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمی‌گردونه، ولی از گوشیِ دیگه نه. برای امن‌تر شدن، اول شماره‌ت رو ثبت کن.' : 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}

نه، می‌مونم - void logout()}>خروج + void logout()}> + {switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'} + +
+
+ ); +} + +/** + * حذفِ همیشگیِ حساب (POST /user/delete). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ + * بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند؛ مهمان با تایپِ «حذف». + */ +function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) { + const { profile } = useAppData(); + const [sent, setSent] = useState(false); + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const needsCode = Boolean(profile.phone); + + async function sendCode() { + if (!profile.phone) return; + setBusy(true); + setError(''); + try { + await api.auth.requestOtp(profile.phone); + setSent(true); + } catch (err) { + setError(friendlyError(err)); + } finally { + setBusy(false); + } + } + + async function remove() { + setBusy(true); + setError(''); + try { + await api.auth.deleteAccount(needsCode ? { otp: digitsOnly(value, 6) } : { confirm: value.trim() }); + askStore.clear(); + onSignedOut(); + } catch (err) { + setError(friendlyError(err)); + } finally { + setBusy(false); + } + } + + return ( + +

+ امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار و اشتراکِ فعالت پاک می‌شن و برنمی‌گردن. + سوابقِ پرداخت فقط برای حسابداری می‌مونه. +

+ {needsCode ? ( + sent ? ( + setValue(digitsOnly(e.target.value, 6))} + placeholder="کدِ پیامک‌شده" + inputMode="numeric" + dir="ltr" + className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-red-400" + /> + ) : ( +

برای اطمینان، یک کد به شماره‌ی خودت پیامک می‌شه.

+ ) + ) : ( + setValue(e.target.value.slice(0, 10))} + placeholder="برای تأیید بنویس: حذف" + className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold outline-none focus:border-red-400" + /> + )} + {error &&
{error}
} +
+ نه، پشیمون شدم + {needsCode && !sent ? ( + void sendCode()}>فرستادنِ کد + ) : ( + void remove()} + > + حذفِ همیشگی + + )}
); diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index d99dd7b..2fd3944 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Check, Crown, Heart, Ticket } from 'lucide-react'; +import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react'; import { api, type Plan } from '@/api'; import { useAsync } from '@/lib/useAsync'; import { Sheet } from '@/components/Sheet'; @@ -40,17 +40,19 @@ export function SubscriptionScreen() {
-

با اشتراک، قلب‌هات هیچ‌وقت تموم نمی‌شن و هر چقدر بخوای تمرین می‌کنی.

+

+ با اشتراک هر روز سوالِ بیشتری از دستیار می‌پرسی و قلب‌هات هیچ‌وقت تموم نمی‌شن. +

{plans.loading ? ( ) : plans.error ? ( - ) : !plans.data?.length ? ( + ) : !plans.data?.plans.length ? ( ) : ( - plans.data.map((plan) => { + plans.data.plans.map((plan) => { const featured = plan.popular; const isCurrent = plan.id === activePlanId; return ( @@ -75,16 +77,27 @@ export function SubscriptionScreen() { )} - {plan.features.length > 0 && ( -
    - {plan.features.map((f) => ( +
      + {/* سقفِ دستیار از خودِ پلن ساخته می‌شود (Plan.ai_daily_quota)، نه از متنِ ویژگی‌ها، + تا عوض کردنِ عدد در ادمین کارت را هم درست عوض کند. */} + {plan.aiDailyQuota > 0 && ( +
    • + + + {toFa(plan.aiDailyQuota)} سوال در روز از دستیارِ «پی» + + {' '}(رایگان: {toFa(plans.data!.freeAiDailyQuota)}) + + +
    • + )} + {plan.features.map((f) => (
    • {f}
    • ))} -
    - )} +