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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user