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>
);
}
+17 -3
View File
@@ -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 (
<AnimatePresence>
@@ -61,7 +63,7 @@ export function FiltersSheet({
حداکثر فاصله
</span>
<span className="text-[13px] font-bold text-(--color-brand-600)">
{maxDistance} کیلومتر
{unlimitedDistance ? "همه‌جا" : `${maxDistance} کیلومتر`}
</span>
</div>
<input
@@ -69,9 +71,21 @@ export function FiltersSheet({
min={1}
max={300}
value={maxDistance}
disabled={unlimitedDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))}
className="w-full accent-(--color-brand-500)"
className="w-full accent-(--color-brand-500) disabled:opacity-40"
/>
<label className="flex items-center gap-2 mt-3 cursor-pointer select-none">
<input
type="checkbox"
checked={unlimitedDistance}
onChange={(e) => setUnlimitedDistance(e.target.checked)}
className="w-4 h-4 accent-(--color-brand-500)"
/>
<span className="text-[13px] text-(--color-content-secondary)">
بدون محدودیت فاصله (توی همه‌جا بگرد)
</span>
</label>
</div>
<div>
@@ -109,7 +123,7 @@ export function FiltersSheet({
isBlock
className="mt-8"
onClick={() => {
onApply({ maxDistance, minAge, maxAge });
onApply({ maxDistance, minAge, maxAge, unlimitedDistance });
onClose();
}}
>
@@ -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 {
@@ -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<ProfileQuestion[] | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>(
initialProfile?.profile_answers.map((a) => a.question_id) ?? []
);
const [answers, setAnswers] = useState<Record<number, string>>(() => {
const map: Record<number, string> = {};
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<string | null>(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 <FullScreenLoader label="سوالات رو میاریم..." />;
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 <FullScreenLoader label="سوالات رو میاریم..." />;
return (
<div className="flex flex-col gap-5">
<p className="text-[13px] font-semibold text-(--color-content-secondary)">
دقیقاً {REQUIRED} تا انتخاب کن ({selectedIds.length}/{REQUIRED})
<div className="flex items-center justify-center gap-2">
{Array.from({ length: REQUIRED }).map((_, i) => (
<button
key={i}
onClick={() => (slots[i] || i < activeSlot) && goToSlot(i)}
className={cn(
"h-1.5 rounded-(--radius-full) transition-all",
i === activeSlot
? "w-8 bg-(--color-brand-500)"
: slots[i]
? "w-4 bg-(--color-brand-200)"
: "w-4 bg-(--color-bg-tertiary)"
)}
/>
))}
</div>
<p className="text-[13px] font-semibold text-(--color-content-secondary) text-center">
سوال {activeSlot + 1} از {REQUIRED}
</p>
<div className="flex flex-col gap-2">
{questions.map((q) => {
const selected = selectedIds.includes(q.id);
const disabled = !selected && selectedIds.length >= REQUIRED;
return (
{activeSlot > 0 && (
<button
onClick={() => goToSlot(activeSlot - 1)}
className="flex items-center gap-1 text-[13px] text-(--color-content-tertiary) w-fit"
>
<ChevronRight size={14} />
سوال قبلی
</button>
)}
{!currentQuestion ? (
<div className="flex flex-col gap-2">
<p className="text-[13px] text-(--color-content-tertiary) px-1">
یکی از این سوال‌ها رو انتخاب کن:
</p>
{availableForSlot.map((q) => (
<button
key={q.id}
onClick={() => toggleQuestion(q.id)}
disabled={disabled}
className={cn(
"flex items-center gap-3 text-right rounded-(--radius-md) border-2 px-4 py-3.5 transition-all",
selected
? "border-(--color-brand-500) bg-(--color-brand-50)"
: "border-(--color-border-default) bg-(--color-surface-default)",
disabled && "opacity-40"
)}
onClick={() => pickQuestion(q.id)}
className="text-right rounded-(--radius-md) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3.5 text-[14px] text-(--color-content-primary) hover:border-(--color-brand-300) transition-colors"
>
<span
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center shrink-0 border-2",
selected
? "bg-(--color-brand-500) border-(--color-brand-500)"
: "border-(--color-border-default)"
)}
>
{selected && <Check size={14} className="text-white" strokeWidth={3} />}
</span>
<span className="text-[14px] text-(--color-content-primary)">{q.text}</span>
{q.text}
</button>
);
})}
</div>
{selectedIds.length > 0 && (
<div className="flex flex-col gap-4 pt-2">
{selectedIds.map((id) => {
const q = questions.find((qq) => qq.id === id);
if (!q) return null;
return (
<div key={id} className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
{q.text}
</label>
<textarea
value={answers[id] ?? ""}
onChange={(e) =>
setAnswers((prev) => ({ ...prev, [id]: e.target.value }))
}
rows={3}
placeholder="جواب بده..."
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
/>
</div>
);
})}
))}
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 px-1">
<label className="text-[14px] font-bold text-(--color-content-primary)">
{currentQuestion.text}
</label>
<button
onClick={clearCurrentQuestion}
className="flex items-center gap-1 text-[12px] font-semibold text-(--color-brand-600) shrink-0"
>
<Pencil size={12} />
عوض کردن سوال
</button>
</div>
<textarea
key={currentQuestion.id}
value={draftAnswer}
onChange={(e) => setDraftAnswer(e.target.value)}
rows={5}
autoFocus
placeholder="جواب بده..."
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
/>
</div>
)}
<ErrorBanner message={error} />
<StepFooterButton disabled={!allAnswered} isLoading={loading} onClick={handleSubmit}>
بریم مرحله بعد
<StepFooterButton
disabled={!canAdvance}
isLoading={loading}
onClick={isLastSlot ? handleSubmit : goNext}
>
{isLastSlot ? "ثبت و ادامه" : "سوال بعدی"}
</StepFooterButton>
</div>
);
+30
View File
@@ -0,0 +1,30 @@
"use client";
export function Switch({
checked,
onChange,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative w-11 h-6 rounded-(--radius-full) transition-colors duration-200 shrink-0 disabled:opacity-50 cursor-pointer ${
checked ? "bg-(--color-brand-500)" : "bg-(--color-bg-tertiary)"
}`}
>
{/* از insetInlineStart استفاده می‌کنیم (نه left/right) تا توی حالت RTL درست جابه‌جا بشه */}
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-(--shadow-cute-sm) transition-all duration-200"
style={{ insetInlineStart: checked ? "22px" : "2px" }}
/>
</button>
);
}
+20
View File
@@ -48,6 +48,9 @@ export interface UserProfile {
is_admin: boolean;
bio?: string;
streak_count: number;
notify_matches: boolean;
notify_messages: boolean;
notify_likes: boolean;
}
export interface TokenPair {
@@ -440,6 +443,23 @@ export function checkIn() {
return authRequest<CheckInResult>("/profile/checkin", { method: "POST" });
}
export interface NotificationSettings {
notify_matches: boolean;
notify_messages: boolean;
notify_likes: boolean;
}
export function updateNotificationSettings(settings: NotificationSettings) {
return authRequest<null>("/profile/notifications", {
method: "PATCH",
body: JSON.stringify(settings),
});
}
export function deleteAccount() {
return authRequest<null>("/profile/me", { method: "DELETE" });
}
// ---------------- دوستیابی ----------------
export function getDiscoverFeed(params?: {
+12
View File
@@ -12,6 +12,18 @@ export function formatDistance(km: number): string {
return `${rounded} کیلومتر`;
}
// سقف حجم عکس سمت کلاینت (موبایل/وب)؛ قبل از اینکه اصلاً آپلود شروع بشه
// جلوی فایل‌های خیلی بزرگ رو می‌گیره. بک‌اند هم خودش جداگانه سقف داره؛ این
// فقط یه فیدبک فوری‌تر به کاربره، نه جایگزین اعتبارسنجی سمت سرور.
export const MAX_PHOTO_SIZE_BYTES = 10 * 1024 * 1024;
export function validatePhotoSize(file: File): string | null {
if (file.size > MAX_PHOTO_SIZE_BYTES) {
return "حجم عکس نباید بیشتر از ۱۰ مگابایت باشه؛ یه عکس کوچیک‌تر انتخاب کن.";
}
return null;
}
export function calcAge(birthDate?: string): number | null {
if (!birthDate) return null;
const b = new Date(birthDate);