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