User-style public ID, tutor quota on plan cards, switch and delete account
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Editing>(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 }) {
|
||||
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
|
||||
{profile.name || 'بینام'} <Pencil size={14} className="text-white/70" />
|
||||
</button>
|
||||
<button type="button" onClick={() => void copyId()} className="mt-0.5 flex items-center gap-1 text-xs text-white/75" aria-label="کپیِ شناسهی کاربری">
|
||||
شناسهی کاربری: <span className="font-bold text-white">{toFa(profile.userId)}</span> <Copy size={11} />
|
||||
</button>
|
||||
<div className="mt-0.5 text-xs text-white/75">
|
||||
شناسهی کاربری: <span dir="ltr" className="font-bold tracking-wide text-white">{profile.publicId}</span>
|
||||
</div>
|
||||
<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>}
|
||||
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
|
||||
@@ -131,8 +120,12 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
/>
|
||||
</Menu>
|
||||
|
||||
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500">
|
||||
<LogOut size={16} /> خروج از حساب
|
||||
<Menu>
|
||||
<Row icon={Repeat} label="ورود با حسابِ دیگر" onClick={() => setEditing('switch')} />
|
||||
<Row icon={LogOut} label="خروج از حساب" onClick={() => setEditing('logout')} />
|
||||
</Menu>
|
||||
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
|
||||
<Trash2 size={14} /> حذفِ حساب
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +133,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
|
||||
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
|
||||
<BirthDateSheet open={editing === 'birth'} onClose={() => setEditing(null)} />
|
||||
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<LogoutSheet open={editing === 'logout' || editing === 'switch'} switching={editing === 'switch'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
<DeleteAccountSheet open={editing === 'delete'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
|
||||
<Sheet open={open} onClose={onClose} title={switching ? 'ورود با حسابِ دیگر' : 'از حسابت خارج بشی؟'}>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||||
{profile.isGuest
|
||||
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمیگردونه، ولی از گوشیِ دیگه نه. برای امنتر شدن، اول شمارهت رو ثبت کن.'
|
||||
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
|
||||
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
||||
{switching ? 'خروج و ورود با حسابِ دیگر' : 'خروج'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* حذفِ همیشگیِ حساب (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 (
|
||||
<Sheet open={open} onClose={onClose} title="حذفِ همیشگیِ حساب">
|
||||
<p className="mb-3 text-sm leading-7 text-body">
|
||||
امتیاز، زنجیره، پیشرفتِ جزیرهها، گفتگوها با دستیار و اشتراکِ فعالت پاک میشن و برنمیگردن.
|
||||
سوابقِ پرداخت فقط برای حسابداری میمونه.
|
||||
</p>
|
||||
{needsCode ? (
|
||||
sent ? (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<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>}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<SecondaryButton onClick={onClose}>نه، پشیمون شدم</SecondaryButton>
|
||||
{needsCode && !sent ? (
|
||||
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void sendCode()}>فرستادنِ کد</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
color="#ef4444"
|
||||
loading={busy}
|
||||
disabled={needsCode ? value.length < 4 : value.trim() !== 'حذف'}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
حذفِ همیشگی
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<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>
|
||||
<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 ? (
|
||||
) : !plans.data?.plans.length ? (
|
||||
<EmptyState text="فعلاً پلنی برای خرید تعریف نشده" />
|
||||
) : (
|
||||
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() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{plan.features.length > 0 && (
|
||||
<ul className="mb-4 flex flex-col gap-2">
|
||||
{plan.features.map((f) => (
|
||||
<ul className="mb-4 flex flex-col gap-2">
|
||||
{/* سقفِ دستیار از خودِ پلن ساخته میشود (Plan.ai_daily_quota)، نه از متنِ ویژگیها،
|
||||
تا عوض کردنِ عدد در ادمین کارت را هم درست عوض کند. */}
|
||||
{plan.aiDailyQuota > 0 && (
|
||||
<li className="flex items-center gap-2 text-sm font-bold">
|
||||
<Sparkles size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
|
||||
<span>
|
||||
{toFa(plan.aiDailyQuota)} سوال در روز از دستیارِ «پی»
|
||||
<span className={`font-medium ${featured ? 'text-white/75' : 'text-muted'}`}>
|
||||
{' '}(رایگان: {toFa(plans.data!.freeAiDailyQuota)})
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
disabled={plan.price === 0 || isCurrent}
|
||||
|
||||
Reference in New Issue
Block a user