Fix discovery/filters bugs, build real settings pages, sequential onboarding questions

- Fix reset-filters button being a no-op (referential-equality bug with
  the DEFAULT_FILTERS constant) and add an unlimited-distance toggle to
  discovery filters
- Replace the "coming soon" settings stubs with working Notifications,
  Privacy & Security, and Help & Support pages
- Rework the onboarding profile-questions step to walk users through
  one question at a time instead of picking 3 then filling them all at once
- Enforce a 10MB client-side cap on photo uploads (onboarding + profile)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 18:10:02 +03:30
parent cbbfb145dc
commit d55d61ba81
12 changed files with 614 additions and 95 deletions
+4 -2
View File
@@ -123,7 +123,9 @@ function DiscoverScreen() {
setError(null);
try {
const data = await getDiscoverFeed({
maxDistanceKm: f.maxDistance,
// وقتی «بدون محدودیت فاصله» فعاله، اصلاً پارامتر فاصله رو نمی‌فرستیم تا
// بک‌اند همه‌ی کاندیداها رو صرف‌نظر از فاصله برگردونه.
maxDistanceKm: f.unlimitedDistance ? undefined : f.maxDistance,
minAge: f.minAge,
maxAge: f.maxAge,
});
@@ -205,7 +207,7 @@ function DiscoverScreen() {
title="کسی با این فیلترها پیدا نشد"
description="محدوده‌ی فاصله یا سن رو بازتر کن، یا بعداً دوباره سر بزن."
actionLabel="بازتنظیم فیلترها"
onAction={() => setFilters(DEFAULT_FILTERS)}
onAction={() => setFilters({ ...DEFAULT_FILTERS })}
/>
</div>
) : (
+6 -1
View File
@@ -25,7 +25,7 @@ import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { useAuth } from "@/lib/auth-context";
import { getProfileMe, uploadPhoto, deletePhoto, updateBio, photoUrl, FullProfile, Photo } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
import { calcAge } from "@/lib/utils";
import { calcAge, validatePhotoSize } from "@/lib/utils";
function selectionTitles(profile: FullProfile, key: string): string[] {
return profile.selections.find((s) => s.category_key === key)?.titles ?? [];
@@ -251,6 +251,11 @@ function PhotosSection({ photos, onChange }: { photos: Photo[]; onChange: (photo
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
const sizeError = validatePhotoSize(file);
if (sizeError) {
setError(sizeError);
return;
}
setUploading(true);
setError(null);
try {
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, ChevronDown, HelpCircle } from "lucide-react";
import { Card } from "@/components/ui/Card";
const FAQ: { q: string; a: string }[] = [
{
q: "چرا پروفایلم هنوز تایید نشده؟",
a: "بعد از تکمیل تمام مراحل ثبت‌نام، یکی از ادمین‌های دلدل پروفایلت رو بررسی می‌کنه. معمولاً این کار زیاد طول نمی‌کشه؛ تا اون موقع می‌تونی هر وقت خواستی برگردی و اطلاعاتت رو ویرایش کنی.",
},
{
q: "چرا پروفایلم رد شد؟",
a: "دلیل رد دقیقاً توی صفحه‌ی اصلی برات نمایش داده می‌شه (مثلاً عکس واضح نیست یا اطلاعات ناقصه). می‌تونی اطلاعاتت رو اصلاح کنی و دوباره برای بررسی بفرستی.",
},
{
q: "فاصله‌ای که کنار هر پروفایل می‌بینم دقیقه؟",
a: "نه، عمداً تقریبیه (گرد شده به نزدیک‌ترین کیلومتر) تا موقعیت دقیق هیچ‌کس لو نره.",
},
{
q: "چطور می‌تونم محدوده‌ی سنی یا فاصله رو تغییر بدم؟",
a: "توی صفحه‌ی کشف، روی آیکون فیلتر (کنار آیکون قلب) بزن. اونجا می‌تونی محدوده‌ی سنی، حداکثر فاصله، یا حتی جستجوی بدون محدودیت فاصله رو فعال کنی.",
},
{
q: "چرا دیگه پروفایل جدیدی برام نمیاد؟",
a: "یعنی با فیلترهای فعلیت، پیشنهاد جدیدی نمونده. فیلترها رو بازتر کن یا بعداً دوباره سر بزن؛ تا اون موقع می‌تونی یه بازی کوچیک هم بزنی!",
},
{
q: "چطور با یکی چت کنم؟",
a: "وقتی دو نفر متقابلاً همدیگه رو لایک کنن، یه «مچ» ساخته می‌شه و بخش پیام‌ها براتون باز می‌شه.",
},
{
q: "می‌تونم شماره موبایلم رو عوض کنم؟",
a: "فعلاً از داخل اپ امکانش نیست. با پشتیبانی در ارتباط باش.",
},
];
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-(--color-border-subtle) last:border-0">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between gap-3 px-4 py-3.5 text-right"
>
<span className="text-[14px] font-bold text-(--color-content-primary)">{q}</span>
<ChevronDown
size={16}
className={`text-(--color-content-tertiary) shrink-0 transition-transform duration-200 ${
open ? "rotate-180" : ""
}`}
/>
</button>
{open && (
<p className="px-4 pb-4 text-[13.5px] text-(--color-content-secondary) leading-6">{a}</p>
)}
</div>
);
}
export default function HelpSettingsPage() {
const router = useRouter();
return (
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
<div className="flex items-center gap-2">
<button
onClick={() => router.back()}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
aria-label="بازگشت"
>
<ChevronRight size={20} className="text-(--color-content-secondary)" />
</button>
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
راهنما و پشتیبانی 💬
</h1>
</div>
<Card padded={false} className="overflow-hidden">
{FAQ.map((item) => (
<FaqItem key={item.q} q={item.q} a={item.a} />
))}
</Card>
<Card className="flex items-start gap-2.5">
<HelpCircle size={16} className="text-(--color-brand-500) shrink-0 mt-0.5" />
<p className="text-[13px] text-(--color-content-secondary) leading-6">
سوالت اینجا نبود؟ فعلاً کانال پشتیبانی مستقیم داخل اپ نداریم؛ به‌زودی اضافه می‌شه.
</p>
</Card>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Heart, MessageCircle, Star } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Switch } from "@/components/ui/Switch";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { getProfileMe, updateNotificationSettings, NotificationSettings } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
const ITEMS: { key: keyof NotificationSettings; icon: React.ReactNode; label: string; hint: string }[] = [
{
key: "notify_matches",
icon: <Heart size={18} className="text-(--color-brand-500)" />,
label: "مچ جدید",
hint: "وقتی با کسی متقابلاً همدیگه رو لایک کردین",
},
{
key: "notify_messages",
icon: <MessageCircle size={18} className="text-(--color-brand-500)" />,
label: "پیام جدید",
hint: "وقتی یکی از مچ‌هات برات پیام می‌فرسته",
},
{
key: "notify_likes",
icon: <Star size={18} className="text-(--color-brand-500)" />,
label: "لایک جدید",
hint: "وقتی یکی پروفایلت رو لایک می‌کنه",
},
];
export default function NotificationsSettingsPage() {
const router = useRouter();
const [settings, setSettings] = useState<NotificationSettings | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
useEffect(() => {
getProfileMe()
.then((p) =>
setSettings({
notify_matches: p.user.notify_matches,
notify_messages: p.user.notify_messages,
notify_likes: p.user.notify_likes,
})
)
.catch((err) => setError(formatApiError(err)));
}, []);
async function handleToggle(key: keyof NotificationSettings, value: boolean) {
if (!settings) return;
const next = { ...settings, [key]: value };
setSettings(next);
setSaving(key);
setError(null);
try {
await updateNotificationSettings(next);
} catch (err) {
setSettings(settings);
setError(formatApiError(err));
} finally {
setSaving(null);
}
}
return (
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
<div className="flex items-center gap-2">
<button
onClick={() => router.back()}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
aria-label="بازگشت"
>
<ChevronRight size={20} className="text-(--color-content-secondary)" />
</button>
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">اعلان‌ها 🔔</h1>
</div>
{error && <ErrorBanner message={error} />}
{!settings ? (
<FullScreenLoader />
) : (
<>
<p className="text-[13px] text-(--color-content-secondary) leading-6 px-1">
این تنظیمات همین الان ذخیره می‌شن. سیستم ارسال اعلان بیرون از اپ (پوش/پیامک) هنوز
راه‌اندازی نشده؛ به‌محض فعال شدنش، دقیقاً همین ترجیحات رو رعایت می‌کنه.
</p>
<Card padded={false} className="overflow-hidden">
{ITEMS.map((item, i) => (
<div
key={item.key}
className={`flex items-center gap-3 px-4 py-3.5 ${
i !== ITEMS.length - 1 ? "border-b border-(--color-border-subtle)" : ""
}`}
>
{item.icon}
<div className="flex-1 min-w-0">
<p className="text-[14px] font-bold text-(--color-content-primary)">
{item.label}
</p>
<p className="text-[12px] text-(--color-content-tertiary)">{item.hint}</p>
</div>
<Switch
checked={settings[item.key]}
disabled={saving === item.key}
onChange={(v) => handleToggle(item.key, v)}
/>
</div>
))}
</Card>
</>
)}
</div>
);
}
+8 -11
View File
@@ -3,14 +3,13 @@
import { useRouter } from "next/navigation";
import { Bell, Shield, HelpCircle, LogOut, ChevronLeft } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { useAuth } from "@/lib/auth-context";
const comingSoonItems = [
{ icon: Bell, label: "اعلان‌ها" },
{ icon: Shield, label: "حریم خصوصی و امنیت" },
{ icon: HelpCircle, label: "راهنما و پشتیبانی" },
const settingsLinks = [
{ icon: Bell, label: "اعلان‌ها", href: "/settings/notifications" },
{ icon: Shield, label: "حریم خصوصی و امنیت", href: "/settings/privacy" },
{ icon: HelpCircle, label: "راهنما و پشتیبانی", href: "/settings/help" },
];
export default function SettingsPage() {
@@ -38,22 +37,20 @@ export default function SettingsPage() {
</Card>
<Card padded={false} className="overflow-hidden">
{comingSoonItems.map((item, i) => {
{settingsLinks.map((item, i) => {
const Icon = item.icon;
return (
<button
key={item.label}
className={`w-full flex items-center gap-3 px-4 py-3.5 text-right ${
i !== comingSoonItems.length - 1 ? "border-b border-(--color-border-subtle)" : ""
onClick={() => router.push(item.href)}
className={`w-full flex items-center gap-3 px-4 py-3.5 text-right hover:bg-(--color-bg-secondary) transition-colors ${
i !== settingsLinks.length - 1 ? "border-b border-(--color-border-subtle)" : ""
}`}
>
<Icon size={18} className="text-(--color-content-secondary) shrink-0" />
<span className="flex-1 text-[14px] text-(--color-content-primary)">
{item.label}
</span>
<Badge tone="neutral" className="text-[10px]">
به‌زودی
</Badge>
<ChevronLeft size={16} className="text-(--color-content-tertiary)" />
</button>
);
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRight, ShieldCheck, Phone, MapPin, Lock, Trash2 } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { useAuth } from "@/lib/auth-context";
import { deleteAccount } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
function ConfirmDeleteSheet({
open,
loading,
onConfirm,
onCancel,
}: {
open: boolean;
loading: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onCancel}
className="fixed inset-0 z-40 bg-black/40"
/>
<motion.div
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 320, damping: 32 }}
className="fixed inset-x-0 bottom-0 z-50 bg-(--color-surface-default) rounded-t-(--radius-xl) shadow-(--shadow-cute-lg) px-6 pt-6 pb-[calc(env(safe-area-inset-bottom)+24px)]"
>
<h3 className="font-[var(--font-display)] font-extrabold text-[18px] mb-2">
حذف حساب کاربری
</h3>
<p className="text-[14px] text-(--color-content-secondary) leading-6 mb-6">
مطمئنی می‌خوای حسابت رو حذف کنی؟ این کار رو نمی‌شه خودت برگردونی.
</p>
<div className="flex gap-2">
<Button variant="ghost" size="lg" className="flex-1" onClick={onCancel}>
انصراف
</Button>
<Button
size="lg"
isLoading={loading}
className="flex-1 !bg-(--color-status-error) !shadow-none hover:!bg-red-700"
onClick={onConfirm}
>
بله، حذف کن
</Button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
const FACTS = [
{
icon: <Phone size={16} className="text-(--color-brand-500)" />,
text: "شماره موبایلت هیچ‌وقت به کاربرهای دیگه نشون داده نمی‌شه.",
},
{
icon: <MapPin size={16} className="text-(--color-brand-500)" />,
text: "موقعیت مکانی دقیقت هیچ‌وقت ذخیره یا نشون داده نمی‌شه؛ فقط یه فاصله‌ی تقریبی و گردشده (مثلاً «۲۰ کیلومتر») محاسبه می‌شه.",
},
{
icon: <Lock size={16} className="text-(--color-brand-500)" />,
text: "ورود فقط با کد یکبارمصرف پیامکیه؛ رمز عبوری برای دزدیده شدن وجود نداره.",
},
{
icon: <ShieldCheck size={16} className="text-(--color-brand-500)" />,
text: "هر پروفایل قبل از دیده شدن توسط بقیه، توسط تیم پشتیبانی بررسی و تایید می‌شه.",
},
];
export default function PrivacySettingsPage() {
const router = useRouter();
const { logout } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDelete() {
setDeleting(true);
setError(null);
try {
await deleteAccount();
logout();
router.push("/login");
} catch (err) {
setError(formatApiError(err));
setConfirmOpen(false);
setDeleting(false);
}
}
return (
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
<div className="flex items-center gap-2">
<button
onClick={() => router.back()}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
aria-label="بازگشت"
>
<ChevronRight size={20} className="text-(--color-content-secondary)" />
</button>
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
حریم خصوصی و امنیت 🔒
</h1>
</div>
{error && <ErrorBanner message={error} />}
<Card className="flex flex-col gap-4">
{FACTS.map((f, i) => (
<div key={i} className="flex items-start gap-2.5">
<div className="mt-0.5 shrink-0">{f.icon}</div>
<p className="text-[13.5px] text-(--color-content-secondary) leading-6">{f.text}</p>
</div>
))}
</Card>
<Card className="flex flex-col gap-3 border-2 !border-red-100">
<div>
<p className="text-[14px] font-bold text-(--color-status-error)">حذف حساب کاربری</p>
<p className="text-[13px] text-(--color-content-tertiary) mt-1 leading-6">
حسابت غیرفعال می‌شه و دیگه نمی‌تونی وارد بشی یا توی دوستیابی دیده بشی. برای
برگردوندنش باید با پشتیبانی تماس بگیری.
</p>
</div>
<Button
variant="tertiary"
icon={<Trash2 size={15} />}
className="!text-(--color-status-error) !border-red-200 w-fit"
onClick={() => setConfirmOpen(true)}
>
حذف حساب من
</Button>
</Card>
<ConfirmDeleteSheet
open={confirmOpen}
loading={deleting}
onConfirm={handleDelete}
onCancel={() => setConfirmOpen(false)}
/>
</div>
);
}