From d55d61ba815ea1a4578446a2e91eacf54def60b8 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sat, 26 Sep 2026 18:10:02 +0330 Subject: [PATCH] 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 --- src/app/(main)/home/page.tsx | 6 +- src/app/(main)/profile/page.tsx | 7 +- src/app/(main)/settings/help/page.tsx | 94 ++++++++ .../(main)/settings/notifications/page.tsx | 118 ++++++++++ src/app/(main)/settings/page.tsx | 19 +- src/app/(main)/settings/privacy/page.tsx | 161 +++++++++++++ src/components/discover/FiltersSheet.tsx | 20 +- .../onboarding/steps/PhotosStep.tsx | 6 + .../onboarding/steps/ProfileQuestionsStep.tsx | 216 +++++++++++------- src/components/ui/Switch.tsx | 30 +++ src/lib/api.ts | 20 ++ src/lib/utils.ts | 12 + 12 files changed, 614 insertions(+), 95 deletions(-) create mode 100644 src/app/(main)/settings/help/page.tsx create mode 100644 src/app/(main)/settings/notifications/page.tsx create mode 100644 src/app/(main)/settings/privacy/page.tsx create mode 100644 src/components/ui/Switch.tsx diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index c1295a1..cdf7cb3 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -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 })} /> ) : ( diff --git a/src/app/(main)/profile/page.tsx b/src/app/(main)/profile/page.tsx index 86ae4e8..8cc8405 100644 --- a/src/app/(main)/profile/page.tsx +++ b/src/app/(main)/profile/page.tsx @@ -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 { diff --git a/src/app/(main)/settings/help/page.tsx b/src/app/(main)/settings/help/page.tsx new file mode 100644 index 0000000..3f01c8f --- /dev/null +++ b/src/app/(main)/settings/help/page.tsx @@ -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 ( +
+ + {open && ( +

{a}

+ )} +
+ ); +} + +export default function HelpSettingsPage() { + const router = useRouter(); + + return ( +
+
+ +

+ راهنما و پشتیبانی 💬 +

+
+ + + {FAQ.map((item) => ( + + ))} + + + + +

+ سوالت اینجا نبود؟ فعلاً کانال پشتیبانی مستقیم داخل اپ نداریم؛ به‌زودی اضافه می‌شه. +

+
+
+ ); +} diff --git a/src/app/(main)/settings/notifications/page.tsx b/src/app/(main)/settings/notifications/page.tsx new file mode 100644 index 0000000..ef44504 --- /dev/null +++ b/src/app/(main)/settings/notifications/page.tsx @@ -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: , + label: "مچ جدید", + hint: "وقتی با کسی متقابلاً همدیگه رو لایک کردین", + }, + { + key: "notify_messages", + icon: , + label: "پیام جدید", + hint: "وقتی یکی از مچ‌هات برات پیام می‌فرسته", + }, + { + key: "notify_likes", + icon: , + label: "لایک جدید", + hint: "وقتی یکی پروفایلت رو لایک می‌کنه", + }, +]; + +export default function NotificationsSettingsPage() { + const router = useRouter(); + const [settings, setSettings] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(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 ( +
+
+ +

اعلان‌ها 🔔

+
+ + {error && } + + {!settings ? ( + + ) : ( + <> +

+ این تنظیمات همین الان ذخیره می‌شن. سیستم ارسال اعلان بیرون از اپ (پوش/پیامک) هنوز + راه‌اندازی نشده؛ به‌محض فعال شدنش، دقیقاً همین ترجیحات رو رعایت می‌کنه. +

+ + {ITEMS.map((item, i) => ( +
+ {item.icon} +
+

+ {item.label} +

+

{item.hint}

+
+ handleToggle(item.key, v)} + /> +
+ ))} +
+ + )} +
+ ); +} diff --git a/src/app/(main)/settings/page.tsx b/src/app/(main)/settings/page.tsx index 6cbdf2d..70c7446 100644 --- a/src/app/(main)/settings/page.tsx +++ b/src/app/(main)/settings/page.tsx @@ -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() { - {comingSoonItems.map((item, i) => { + {settingsLinks.map((item, i) => { const Icon = item.icon; return ( ); diff --git a/src/app/(main)/settings/privacy/page.tsx b/src/app/(main)/settings/privacy/page.tsx new file mode 100644 index 0000000..89a840b --- /dev/null +++ b/src/app/(main)/settings/privacy/page.tsx @@ -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 ( + + {open && ( + <> + + +

+ حذف حساب کاربری +

+

+ مطمئنی می‌خوای حسابت رو حذف کنی؟ این کار رو نمی‌شه خودت برگردونی. +

+
+ + +
+
+ + )} +
+ ); +} + +const FACTS = [ + { + icon: , + text: "شماره موبایلت هیچ‌وقت به کاربرهای دیگه نشون داده نمی‌شه.", + }, + { + icon: , + text: "موقعیت مکانی دقیقت هیچ‌وقت ذخیره یا نشون داده نمی‌شه؛ فقط یه فاصله‌ی تقریبی و گردشده (مثلاً «۲۰ کیلومتر») محاسبه می‌شه.", + }, + { + icon: , + text: "ورود فقط با کد یکبارمصرف پیامکیه؛ رمز عبوری برای دزدیده شدن وجود نداره.", + }, + { + icon: , + 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(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 ( +
+
+ +

+ حریم خصوصی و امنیت 🔒 +

+
+ + {error && } + + + {FACTS.map((f, i) => ( +
+
{f.icon}
+

{f.text}

+
+ ))} +
+ + +
+

حذف حساب کاربری

+

+ حسابت غیرفعال می‌شه و دیگه نمی‌تونی وارد بشی یا توی دوستیابی دیده بشی. برای + برگردوندنش باید با پشتیبانی تماس بگیری. +

+
+ +
+ + setConfirmOpen(false)} + /> +
+ ); +} diff --git a/src/components/discover/FiltersSheet.tsx b/src/components/discover/FiltersSheet.tsx index f6ecf3c..f868388 100644 --- a/src/components/discover/FiltersSheet.tsx +++ b/src/components/discover/FiltersSheet.tsx @@ -9,6 +9,7 @@ export interface DiscoverFilters { maxDistance: number; minAge: number; maxAge: number; + unlimitedDistance?: boolean; } export function FiltersSheet({ @@ -25,6 +26,7 @@ export function FiltersSheet({ const [maxDistance, setMaxDistance] = useState(initial.maxDistance); const [minAge, setMinAge] = useState(initial.minAge); const [maxAge, setMaxAge] = useState(initial.maxAge); + const [unlimitedDistance, setUnlimitedDistance] = useState(!!initial.unlimitedDistance); return ( @@ -61,7 +63,7 @@ export function FiltersSheet({ حداکثر فاصله - {maxDistance} کیلومتر + {unlimitedDistance ? "همه‌جا" : `${maxDistance} کیلومتر`} setMaxDistance(Number(e.target.value))} - className="w-full accent-(--color-brand-500)" + className="w-full accent-(--color-brand-500) disabled:opacity-40" /> +
@@ -109,7 +123,7 @@ export function FiltersSheet({ isBlock className="mt-8" onClick={() => { - onApply({ maxDistance, minAge, maxAge }); + onApply({ maxDistance, minAge, maxAge, unlimitedDistance }); onClose(); }} > diff --git a/src/components/onboarding/steps/PhotosStep.tsx b/src/components/onboarding/steps/PhotosStep.tsx index f920502..0e58a38 100644 --- a/src/components/onboarding/steps/PhotosStep.tsx +++ b/src/components/onboarding/steps/PhotosStep.tsx @@ -6,6 +6,7 @@ import { StepFooterButton } from "@/components/onboarding/StepFooterButton"; import { ErrorBanner } from "@/components/ui/ErrorBanner"; import { confirmPhotosStep, uploadPhoto, deletePhoto, photoUrl, FullProfile, Photo } from "@/lib/api"; import { formatApiError } from "@/lib/hooks"; +import { validatePhotoSize } from "@/lib/utils"; const MAX_PHOTOS = 6; @@ -26,6 +27,11 @@ export function PhotosStep({ 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 { diff --git a/src/components/onboarding/steps/ProfileQuestionsStep.tsx b/src/components/onboarding/steps/ProfileQuestionsStep.tsx index 67d84fe..5922cb4 100644 --- a/src/components/onboarding/steps/ProfileQuestionsStep.tsx +++ b/src/components/onboarding/steps/ProfileQuestionsStep.tsx @@ -12,10 +12,18 @@ import { FullProfile, } from "@/lib/api"; import { formatApiError } from "@/lib/hooks"; -import { Check } from "lucide-react"; +import { ChevronRight, Pencil } from "lucide-react"; const REQUIRED = 3; +interface SlotAnswer { + questionId: number; + answer: string; +} + +// این مرحله به‌جای اینکه یهو ۳ تا سوال رو با هم جلوی کاربر بذاره (که خسته‌کننده +// و شلوغه)، یکی‌یکی جلو می‌بره: اول انتخاب سوال، بعد جواب همون سوال، بعد سوال +// بعدی — دقیقاً مثل یه مصاحبه‌ی کوتاه، نه یه فرم بلند. export function ProfileQuestionsStep({ initialProfile, onNext, @@ -24,16 +32,20 @@ export function ProfileQuestionsStep({ onNext: () => void; }) { const [questions, setQuestions] = useState(null); - const [selectedIds, setSelectedIds] = useState( - initialProfile?.profile_answers.map((a) => a.question_id) ?? [] - ); - const [answers, setAnswers] = useState>(() => { - const map: Record = {}; - initialProfile?.profile_answers.forEach((a) => { - map[a.question_id] = a.answer; - }); - return map; + const [slots, setSlots] = useState<(SlotAnswer | null)[]>(() => { + const initial = initialProfile?.profile_answers.slice(0, REQUIRED) ?? []; + const arr: (SlotAnswer | null)[] = initial.map((a) => ({ + questionId: a.question_id, + answer: a.answer, + })); + while (arr.length < REQUIRED) arr.push(null); + return arr; }); + const [activeSlot, setActiveSlot] = useState(() => { + const firstEmpty = slots.findIndex((s) => s === null); + return firstEmpty === -1 ? REQUIRED - 1 : firstEmpty; + }); + const [draftAnswer, setDraftAnswer] = useState(slots[activeSlot]?.answer ?? ""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -43,25 +55,62 @@ export function ProfileQuestionsStep({ .catch((err) => setError(formatApiError(err))); }, []); - function toggleQuestion(id: number) { - setSelectedIds((prev) => { - if (prev.includes(id)) return prev.filter((x) => x !== id); - if (prev.length >= REQUIRED) return prev; - return [...prev, id]; - }); + if (!questions) return ; + + const usedQuestionIds = slots + .filter((s, i) => s && i !== activeSlot) + .map((s) => s!.questionId); + const currentQuestion = questions.find((q) => q.id === slots[activeSlot]?.questionId) ?? null; + const availableForSlot = questions.filter((q) => !usedQuestionIds.includes(q.id)); + + function saveDraftInto(list: (SlotAnswer | null)[]) { + const next = [...list]; + if (next[activeSlot]) { + next[activeSlot] = { ...next[activeSlot]!, answer: draftAnswer.trim() }; + } + return next; } - const allAnswered = - selectedIds.length === REQUIRED && - selectedIds.every((id) => (answers[id] ?? "").trim().length > 0); + function pickQuestion(id: number) { + setSlots((prev) => { + const next = [...prev]; + next[activeSlot] = { questionId: id, answer: "" }; + return next; + }); + setDraftAnswer(""); + } + + function clearCurrentQuestion() { + setSlots((prev) => { + const next = [...prev]; + next[activeSlot] = null; + return next; + }); + setDraftAnswer(""); + } + + function goToSlot(index: number) { + setSlots((prev) => saveDraftInto(prev)); + setActiveSlot(index); + setDraftAnswer(slots[index]?.answer ?? ""); + } + + function goNext() { + setSlots((prev) => saveDraftInto(prev)); + goToSlot(activeSlot + 1); + } + + const isLastSlot = activeSlot === REQUIRED - 1; + const canAdvance = currentQuestion !== null && draftAnswer.trim().length > 0; async function handleSubmit() { - if (!allAnswered) return; + const finalSlots = saveDraftInto(slots); + if (finalSlots.some((s) => !s || !s.answer)) return; setLoading(true); setError(null); try { await submitProfileQuestions( - selectedIds.map((id) => ({ question_id: id, answer: answers[id].trim() })) + finalSlots.map((s) => ({ question_id: s!.questionId, answer: s!.answer })) ); onNext(); } catch (err) { @@ -71,76 +120,87 @@ export function ProfileQuestionsStep({ } } - if (!questions) return ; - return (
-

- دقیقاً {REQUIRED} تا انتخاب کن ({selectedIds.length}/{REQUIRED}) +

+ {Array.from({ length: REQUIRED }).map((_, i) => ( +
+

+ سوال {activeSlot + 1} از {REQUIRED}

-
- {questions.map((q) => { - const selected = selectedIds.includes(q.id); - const disabled = !selected && selectedIds.length >= REQUIRED; - return ( + {activeSlot > 0 && ( + + )} + + {!currentQuestion ? ( +
+

+ یکی از این سوال‌ها رو انتخاب کن: +

+ {availableForSlot.map((q) => ( - ); - })} -
- - {selectedIds.length > 0 && ( -
- {selectedIds.map((id) => { - const q = questions.find((qq) => qq.id === id); - if (!q) return null; - return ( -
- -