Remove every guest remnant: profile badge, register flow, delete-by-word

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 18:20:10 +03:30
parent af2e1f57dd
commit 0db6526933
7 changed files with 35 additions and 114 deletions

View File

@@ -39,17 +39,16 @@ export const api = {
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }, { timeoutMs: 45_000 }), http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }, { timeoutMs: 45_000 }),
/** /**
* گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه می‌دارد و * گامِ ۲: تأییدِ کد. شماره‌ی تازه حسابِ تازه می‌سازد.
* پیشرفتش از دست نمی‌رود (توکنِ مهمان همراهِ همین درخواست می‌رود).
*/ */
login: (phone: string, otp: string) => login: (phone: string, otp: string) =>
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens), http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
/** /**
* حذفِ همیشگیِ حساب. کاربرِ شماره‌دار کدِ تازه‌ی پیامکی می‌دهد (اول requestOtp با شماره‌ی خودش)، * حذفِ همیشگیِ حساب. کاربر کدِ تازه‌ی پیامکی می‌دهد (اول requestOtp با شماره‌ی خودش).
* مهمان کلمه‌ی «حذف» را. بعد از موفقیت توکن‌ها پاک می‌شوند؛ دیگر کاربری پشتشان نیست. * بعد از موفقیت توکن‌ها پاک می‌شوند؛ دیگر کاربری پشتشان نیست.
*/ */
deleteAccount: (body: { otp?: string; confirm?: string }) => deleteAccount: (body: { otp: string }) =>
http.post<{ success: boolean }>('/user/delete', body).then((r) => { http.post<{ success: boolean }>('/user/delete', body).then((r) => {
http.storage.set(null); http.storage.set(null);
return r; return r;
@@ -208,7 +207,7 @@ export const api = {
affiliate: { affiliate: {
me: () => http.get<T.AffiliateMe>('/affiliate/me'), me: () => http.get<T.AffiliateMe>('/affiliate/me'),
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */ /** کاربرِ تازه را به صاحبِ کدِ معرف وصل می‌کند. */
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }), track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
}, },
}; };

View File

@@ -14,7 +14,6 @@ export interface AuthResponse {
token: string; token: string;
refreshToken: string; refreshToken: string;
isNewUser?: boolean; isNewUser?: boolean;
isGuest?: boolean;
} }
export interface Grade { export interface Grade {
@@ -33,7 +32,6 @@ export interface UserProfile {
phone: string | null; phone: string | null;
name: string; name: string;
avatar: string; avatar: string;
isGuest: boolean;
gradeId: number | null; gradeId: number | null;
birthYear: number | null; birthYear: number | null;
/** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */ /** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */

View File

@@ -34,7 +34,7 @@ export function App() {
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */ /** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => { const enter = useCallback(async () => {
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]); const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
await trackReferral(profile); await trackReferral();
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades }); if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
else setFlow({ kind: 'setup', profile, grades }); else setFlow({ kind: 'setup', profile, grades });
}, []); }, []);
@@ -124,7 +124,6 @@ export function App() {
grades={flow.grades} grades={flow.grades}
payment={launchPayment} payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })} onSignedOut={() => setFlow({ kind: 'login' })}
onRegistered={trackReferral}
/> />
)} )}
</motion.div> </motion.div>
@@ -135,13 +134,10 @@ export function App() {
); );
} }
/** /** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
* اگر کاربر با لینکِ دعوت آمده بود و حالا با شماره وارد شده، معرفی را ثبت کن. async function trackReferral() {
* مهمان را سرور نمی‌پذیرد، پس کد تا ثبتِ شماره نگه داشته می‌شود.
*/
async function trackReferral(profile: UserProfile) {
const code = store.get(KEYS.referralCode); const code = store.get(KEYS.referralCode);
if (!code || profile.isGuest) return; if (!code) return;
try { try {
await api.affiliate.track(code); await api.affiliate.track(code);
store.remove(KEYS.referralCode); store.remove(KEYS.referralCode);

View File

@@ -7,10 +7,8 @@ import { NavigatorProvider, useNav } from '@/nav/Navigator';
import { isTab, type Route } from '@/nav/routes'; import { isTab, type Route } from '@/nav/routes';
import { BottomNav } from '@/components/BottomNav'; import { BottomNav } from '@/components/BottomNav';
import { Sheet } from '@/components/Sheet'; import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { LoadingState, PrimaryButton } from '@/components/ui'; import { LoadingState, PrimaryButton } from '@/components/ui';
import { HomeScreen } from '@/screens/HomeScreen'; import { HomeScreen } from '@/screens/HomeScreen';
import { LoginScreen } from '@/screens/LoginScreen';
import type { PaymentReturn } from './launchParams'; import type { PaymentReturn } from './launchParams';
/** /**
@@ -54,23 +52,22 @@ interface Props {
grades: Grade[]; grades: Grade[];
payment: PaymentReturn | null; payment: PaymentReturn | null;
onSignedOut: () => void; onSignedOut: () => void;
onRegistered: (profile: UserProfile) => Promise<void>;
} }
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) { export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []); useEffect(preloadScreens, []);
return ( return (
<AppDataProvider initialProfile={profile} grades={grades}> <AppDataProvider initialProfile={profile} grades={grades}>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}> <NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} /> <Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />} {payment && <PaymentResult payment={payment} />}
</NavigatorProvider> </NavigatorProvider>
</AppDataProvider> </AppDataProvider>
); );
} }
function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onRegistered'>) { function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
const nav = useNav(); const nav = useNav();
const { route } = nav; const { route } = nav;
const showNav = isTab(route.name) && nav.tab === route.name; const showNav = isTab(route.name) && nav.tab === route.name;
@@ -88,7 +85,7 @@ function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onR
transition={{ duration: 0.18 }} transition={{ duration: 0.18 }}
> >
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} /> <RouteView route={route} onSignedOut={onSignedOut} />
</Suspense> </Suspense>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
@@ -98,7 +95,7 @@ function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onR
); );
} }
function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick<Props, 'onSignedOut' | 'onRegistered'>) { function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
switch (route.name) { switch (route.name) {
case 'home': return <HomeScreen />; case 'home': return <HomeScreen />;
case 'map': return <MapScreen />; case 'map': return <MapScreen />;
@@ -116,34 +113,9 @@ function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick
case 'report': return <ReportScreen />; case 'report': return <ReportScreen />;
case 'invoices': return <InvoicesScreen />; case 'invoices': return <InvoicesScreen />;
case 'invite': return <InviteScreen />; case 'invite': return <InviteScreen />;
case 'register': return <RegisterView onRegistered={onRegistered} />;
} }
} }
/** مهمانی که شماره ثبت می‌کند؛ همان کاربر می‌ماند و فقط پروفایل تازه می‌شود. */
function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
const nav = useNav();
const { setProfile } = useAppData();
const toast = useToast();
return (
<LoginScreen
upgrade
onCancel={nav.back}
onDone={async () => {
try {
const profile = await api.profile.get();
setProfile(profile);
await onRegistered(profile);
} catch {
/* ورود انجام شده؛ پروفایل با بازگشت به اپ تازه می‌شود */
}
toast('شماره‌ت ثبت شد! پیشرفتت امنه.', 'success');
nav.back();
}}
/>
);
}
/** /**
* نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر * نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
* لینکِ ساختگی می‌تواند «paid» بگذارد. * لینکِ ساختگی می‌تواند «paid» بگذارد.

View File

@@ -18,8 +18,7 @@ export type Route =
| { name: 'report' } | { name: 'report' }
/** پرداخت‌ها و فاکتورهای خودِ کاربر. */ /** پرداخت‌ها و فاکتورهای خودِ کاربر. */
| { name: 'invoices' } | { name: 'invoices' }
| { name: 'invite' } | { name: 'invite' };
| { name: 'register' };
export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile'; export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile';

View File

@@ -11,20 +11,9 @@ const OTP_LENGTH = 6;
const RESEND_SECONDS = 120; const RESEND_SECONDS = 120;
/** /**
* ورود با شماره (OTP). ورودِ مهمان نداریم: هر کاربر حداقل با شماره ثبت‌نام می‌کند. * ورود با شماره (OTP): تنها راهِ ورود است.
*
* `upgrade` برای حسابِ مهمانِ قدیمی است (دیگر ساخته نمی‌شود) که از صفحه‌ی «من»
* شماره ثبت می‌کند؛ سرور همان کاربر را نگه می‌دارد.
*/ */
export function LoginScreen({ export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel?: () => void }) {
onDone,
onCancel,
upgrade = false,
}: {
onDone: () => void;
onCancel?: () => void;
upgrade?: boolean;
}) {
const [step, setStep] = useState<'phone' | 'otp'>('phone'); const [step, setStep] = useState<'phone' | 'otp'>('phone');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
@@ -117,10 +106,10 @@ export function LoginScreen({
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-black">{upgrade ? 'ثبتِ شماره‌ی موبایل' : 'ورود به تدریسینو'}</h1> <h1 className="text-xl font-black">ورود به تدریسینو</h1>
<p className="mt-1 text-sm text-white/75"> <p className="mt-1 text-sm text-white/75">
{step === 'phone' {step === 'phone'
? upgrade ? 'امتیازها و پیشرفتت سرِ جاش می‌مونه' : 'شماره‌ی موبایلت رو وارد کن' ? 'شماره‌ی موبایلت رو وارد کن'
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`} : `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`}
</p> </p>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { import {
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut, BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
FileText, Pencil, Repeat, Smartphone, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX, FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
@@ -48,11 +48,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80"> <div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>} {gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>} {age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
{profile.isGuest ? ( {profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>}
<span className="rounded-full bg-[#FFD23F] px-2.5 py-0.5 font-bold text-ink">مهمان</span>
) : (
profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -72,21 +68,6 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
</header> </header>
<div className="flex flex-col gap-4 px-4 py-5"> <div className="flex flex-col gap-4 px-4 py-5">
{profile.isGuest && (
<button
type="button"
onClick={go({ name: 'register' })}
className="flex items-center gap-3 rounded-3xl bg-[#FFF8E1] p-4 text-right ring-2 ring-[#FFD23F]"
>
<Smartphone size={22} className="flex-shrink-0 text-gold" />
<div className="flex-1">
<div className="text-sm font-black text-ink">شماره‌ت رو ثبت کن</div>
<div className="mt-0.5 text-xs leading-6 text-body">تا اگه گوشیت عوض شد، امتیازها و پیشرفتت گم نشه.</div>
</div>
<ChevronLeft size={18} className="text-muted" />
</button>
)}
<Menu> <Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} /> <Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row <Row
@@ -106,7 +87,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
</Menu> </Menu>
<Menu> <Menu>
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />} <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />
<Row icon={FileText} label="پرداخت‌ها و فاکتورها" onClick={go({ name: 'invoices' })} /> <Row icon={FileText} label="پرداخت‌ها و فاکتورها" onClick={go({ name: 'invoices' })} />
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} /> <Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
<Row <Row
@@ -273,7 +254,6 @@ function BirthDateSheet({ open, onClose }: { open: boolean; onClose: () => void
* دیگری می‌شد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد. * دیگری می‌شد، و چند refresh tokenِ ماندگار در مرورگر خطرِ دزدیده شدن را چند برابر می‌کرد.
*/ */
function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) { function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open: boolean; switching?: boolean; onClose: () => void; onSignedOut: () => void }) {
const { profile } = useAppData();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
async function logout() { async function logout() {
@@ -291,9 +271,7 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}> <Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
<p className="mb-5 text-sm leading-7 text-muted"> <p className="mb-5 text-sm leading-7 text-muted">
{switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '} {switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '}
{profile.isGuest هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمی‌گردونه، ولی از گوشیِ دیگه نه. برای امن‌تر شدن، اول شماره‌ت رو ثبت کن.'
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
</p> </p>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton> <SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
@@ -307,7 +285,7 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
/** /**
* حذفِ همیشگیِ حساب (POST /user/delete). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ * حذفِ همیشگیِ حساب (POST /user/delete). شماره‌دار با کدِ تازه‌ی پیامکی تأیید می‌کند تا گوشیِ
* بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند؛ مهمان با تایپِ «حذف». * بازمانده یا توکنِ دزدیده نتواند حساب را پاک کند.
*/ */
function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) { function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const { profile } = useAppData(); const { profile } = useAppData();
@@ -315,7 +293,6 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const needsCode = Boolean(profile.phone);
async function sendCode() { async function sendCode() {
if (!profile.phone) return; if (!profile.phone) return;
@@ -335,7 +312,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
setBusy(true); setBusy(true);
setError(''); setError('');
try { try {
await api.auth.deleteAccount(needsCode ? { otp: digitsOnly(value, 6) } : { confirm: value.trim() }); await api.auth.deleteAccount({ otp: digitsOnly(value, 6) });
askStore.clear(); askStore.clear();
onSignedOut(); onSignedOut();
} catch (err) { } catch (err) {
@@ -351,8 +328,7 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار، سوال‌های خریده‌شده و قلبِ نامحدودت پاک می‌شن و برنمی‌گردن. امتیاز، زنجیره، پیشرفتِ جزیره‌ها، گفتگوها با دستیار، سوال‌های خریده‌شده و قلبِ نامحدودت پاک می‌شن و برنمی‌گردن.
سوابقِ پرداخت فقط برای حسابداری می‌مونه. سوابقِ پرداخت فقط برای حسابداری می‌مونه.
</p> </p>
{needsCode ? ( {sent ? (
sent ? (
<input <input
value={value} value={value}
onChange={(e) => setValue(digitsOnly(e.target.value, 6))} onChange={(e) => setValue(digitsOnly(e.target.value, 6))}
@@ -363,25 +339,17 @@ function DeleteAccountSheet({ open, onClose, onSignedOut }: { open: boolean; onC
/> />
) : ( ) : (
<p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره‌ی خودت پیامک می‌شه.</p> <p className="mb-3 text-xs leading-6 text-muted">برای اطمینان، یک کد به شماره‌ی خودت پیامک می‌شه.</p>
)
) : (
<input
value={value}
onChange={(e) => 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 && <div className="mb-3"><InlineError>{error}</InlineError></div>} {error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton> <SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
{needsCode && !sent ? ( {!sent ? (
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton> <PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
) : ( ) : (
<PrimaryButton <PrimaryButton
color="#ef4444" color="#ef4444"
loading={busy} loading={busy}
disabled={needsCode ? value.length < 6 : value.trim() !== 'حذف'} disabled={value.length < 6}
onClick={() => void remove()} onClick={() => void remove()}
> >
حذفِ همیشگی حذفِ همیشگی