Initial commit: dating app web frontend (Next.js)

Phone+OTP auth, staged onboarding, admin-approval-gated home screen,
and the full discovery/match/chat UI (swipe deck with drag gestures,
match modal, profile detail, filters, messages/chat) wired to the
real backend, styled after a Figma dating-app UI kit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 23:24:34 +03:30
commit 93f6ea2f07
63 changed files with 11802 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# آدرس بک‌اند Go (بدون اسلش آخر)
NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env*.example
# vercel
.vercel
# فایل‌های آپلودشده‌ی بک‌اند (اگه به اشتباه اینجا کنار بک‌اند اجرا بشه)
/uploads/
# typescript
*.tsbuildinfo
next-env.d.ts
+103
View File
@@ -0,0 +1,103 @@
# دلدل — فرانت PWA اپ دیتینگ (Next.js + Basis Design System)
فرانت موبایل‌محور و PWA برای بک‌اند Go/Gin/GORM که قبلاً ساختیم، با همون
Basis Design System (نسخه‌ی شاد و گرد Atlas): قرمز پرانرژی، گوشه‌های گرد،
سایه‌های نرم، فونت Vazirmatn + Baloo 2.
## ویژگی‌ها
- ورود با شماره موبایل + کد OTP (بدون پسورد)
- ۸ مرحله‌ی آنبوردینگ دقیقاً مطابق API بک‌اند (با state machine که جلوی پرش
از مرحله رو می‌گیره، ولی اجازه‌ی برگشت و ویرایش می‌ده)
- صفحه‌ی پروفایل کامل با دکمه‌ی ویرایش روی هر بخش (برمی‌گرده به همون مرحله‌ی
آنبوردینگ، چون بک‌اند بعد از تکمیل کامل اجازه‌ی ویرایش هر مرحله رو می‌ده)
- شبیه‌سازی یک اپ سه‌تبه (کشف/پروفایل/تنظیمات) — تب «کشف» چون بک‌اند فعلاً
matching نداره، حالت «به‌زودی» نشون می‌ده
- PWA کامل: قابل نصب روی گوشی (`manifest.webmanifest` + service worker +
آیکون)، تمام صفحات موبایل‌فرست و محدود به عرض یک گوشی
## ✅ تست شده
قبل از تحویل، کل مسیر کاربر (درخواست OTP → تایید → ۸ مرحله‌ی آنبوردینگ →
پروفایل نهایی → رفرش توکن) با یک اسکریپت Node که دقیقاً همون درخواست‌های
`src/lib/api.ts` رو می‌فرسته، روی یک نمونه‌ی واقعی از بک‌اند گو اجرا و با
موفقیت پاس شد — شامل تست‌های منفی (OTP اشتباه، انتخاب ناقص علاقه‌مندی).
یعنی قرارداد بین این فرانت و بک‌اندی که قبلاً تحویل گرفتی، تضمین‌شده هم‌خونه.
## راه‌اندازی
### ۱) تنظیم آدرس بک‌اند
```bash
cp .env.local.example .env.local
```
و `NEXT_PUBLIC_API_URL` رو به آدرس واقعی بک‌اندت تغییر بده (پیش‌فرض
`http://localhost:8080/api/v1`).
### ۲) نصب و اجرا
```bash
npm install
npm run dev
```
`http://localhost:3000` رو باز کن. برای تست موبایل واقعی، از ابزار
device-toolbar مرورگر استفاده کن یا با آی‌پی لوکال شبکه از گوشی وصل شو.
### ۳) نصب به‌عنوان PWA
- **اندروید/کروم:** منوی مرورگر → «Add to Home screen»
- **iOS/Safari:** دکمه‌ی Share → «Add to Home Screen»
بعد از نصب، اپ بدون نوار آدرس مرورگر و با آیکون اختصاصی روی صفحه‌ی اصلی گوشی
باز می‌شه.
## ساختار پروژه
```
src/
├── app/
│ ├── layout.tsx ← فونت، متادیتای PWA، AuthProvider
│ ├── page.tsx ← ریدایرکت اولیه بر اساس وضعیت کاربر
│ ├── login/page.tsx ← ورود با شماره موبایل
│ ├── verify/page.tsx ← تایید کد OTP
│ ├── onboarding/
│ │ ├── [slug]/page.tsx ← صفحه‌ی داینامیک هر ۸ مرحله
│ │ └── done/page.tsx ← صفحه‌ی تبریک پایان آنبوردینگ
│ └── (main)/ ← اپ اصلی بعد از آنبوردینگ (با BottomNav)
│ ├── layout.tsx
│ ├── home/page.tsx ← کشف (placeholder "به‌زودی")
│ ├── profile/page.tsx ← پروفایل کامل + دکمه‌ی ویرایش هر بخش
│ └── settings/page.tsx ← خروج از حساب + آیتم‌های "به‌زودی"
├── components/
│ ├── ui/ ← کامپوننت‌های پایه‌ی دیزاین‌سیستم
│ ├── onboarding/ ← wrapper و ۸ کامپوننت هر مرحله
│ └── BottomNav.tsx
└── lib/
├── api.ts ← کلاینت تایپ‌شده‌ی کامل API (منطبق با بک‌اند گو)
├── auth-context.tsx ← مدیریت وضعیت ورود/کاربر/آنبوردینگ
├── token-store.ts ← ذخیره‌ی توکن‌ها در localStorage
├── onboarding-steps.ts ← ترتیب و متادیتای ۸ مرحله
└── hooks.ts ← useRequireAuth، فرمت خطا
```
## نکات فنی مهم
- **رفرش خودکار توکن:** اگه access token منقضی بشه، `api.ts` خودکار با
refresh token یه توکن جدید می‌گیره و درخواست رو یک‌بار دوباره می‌فرسته؛
کاربر چیزی حس نمی‌کنه.
- **گارد مراحل آنبوردینگ:** هر صفحه‌ی مرحله چک می‌کنه که کاربر اجازه‌ی
دیدن اون مرحله رو داره یا نه (بر اساس `current_step` از بک‌اند)؛ اگه
بخواد از روی مرحله بپره، خودکار به مرحله‌ی درست برمی‌گرده.
- **فونت self-hosted:** مثل دیزاین‌سیستم قبلی، فونت‌ها از `@fontsource-variable`
میان (نه `next/font/google`) تا هیچ‌وقت به گوگل وصل نشه.
- **CORS:** بک‌اند گو از قبل CORS باز داره، پس این فرانت روی هر پورت/دامنه‌ای
بدون تنظیم اضافه بهش وصل می‌شه.
## محدودیت شناخته‌شده
تب «کشف» (matching/کشف افراد) و بخش چت هنوز در بک‌اند پیاده نشدن، پس فقط
یک EmptyState با برچسب «به‌زودی» نشون داده می‌شه. هر وقت اون endpoint ها
رو ساختی، فقط کافیه `src/app/(main)/home/page.tsx` رو با فراخوانی API واقعی
جایگزین کنی.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+6858
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "dating-app-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@fontsource-variable/baloo-2": "^5.3.0",
"@fontsource-variable/vazirmatn": "^5.3.0",
"clsx": "^2.1.1",
"framer-motion": "^13.3.0",
"lucide-react": "^1.45.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+33
View File
@@ -0,0 +1,33 @@
{
"name": "دلدل — آشنایی و دوستیابی",
"short_name": "دلدل",
"description": "اپلیکیشن آشنایی و دوستیابی، شاد و صمیمی",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#fff8f5",
"theme_color": "#ff4433",
"lang": "fa",
"dir": "rtl",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+38
View File
@@ -0,0 +1,38 @@
const CACHE_NAME = "deldel-cache-v1";
const APP_SHELL = ["/", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).catch(() => {})
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
// استراتژی: برای API همیشه شبکه (never cache)، برای بقیه network-first با fallback به کش
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method !== "GET") return;
if (url.pathname.startsWith("/api")) return;
event.respondWith(
fetch(event.request)
.then((res) => {
const resClone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, resClone));
return res;
})
.catch(() => caches.match(event.request))
);
});
+317
View File
@@ -0,0 +1,317 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRight, MapPin, X, Heart, Star, User, Ruler, Weight, GraduationCap, Briefcase, Sparkles, Search } from "lucide-react";
import { Tag } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { CandidateDetail, getCandidateDetail, photoUrl, swipe } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
import { formatDistance } from "@/lib/utils";
function SectionCard({
icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-(--color-content-secondary)">
{icon}
<span className="text-[13px] font-bold">{title}</span>
</div>
{children}
</Card>
);
}
export default function ProfileDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const id = Number(params.id);
const [profile, setProfile] = useState<CandidateDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [photoIndex, setPhotoIndex] = useState(0);
const [viewerOpen, setViewerOpen] = useState(false);
const [acting, setActing] = useState(false);
useEffect(() => {
getCandidateDetail(id)
.then(setProfile)
.catch((err) => setError(formatApiError(err, "این پروفایل پیدا نشد")));
}, [id]);
async function handleSwipe(action: "like" | "pass" | "superlike") {
setActing(true);
try {
const result = await swipe(id, action);
if (result.matched && result.match) {
router.push(`/messages/${result.match.id}`);
return;
}
} catch {
// اگه سواپ با خطا مواجه بشه، بازم کاربر رو برمی‌گردونیم به کشف
}
router.push("/home");
}
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
<ErrorBanner message={error} />
<button
onClick={() => router.push("/home")}
className="text-[13px] font-bold text-(--color-brand-600)"
>
برگرد به کشف
</button>
</div>
);
}
if (!profile) return <FullScreenLoader />;
function goTo(i: number) {
if (!profile) return;
setPhotoIndex(Math.max(0, Math.min(profile.photos.length - 1, i)));
}
const photo = profile.photos[photoIndex];
return (
<div className="flex flex-col min-h-full">
<div className="relative w-full aspect-[4/5] bg-(--color-bg-tertiary)">
{photo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl(photo)}
alt={profile.first_name}
className="absolute inset-0 w-full h-full object-cover cursor-zoom-in"
onClick={() => setViewerOpen(true)}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-(--color-content-disabled)">
<User size={72} strokeWidth={1.2} />
</div>
)}
{profile.photos.length > 0 && (
<div className="absolute inset-x-0 top-0 flex gap-1.5 p-3 pt-[calc(env(safe-area-inset-top)+8px)]">
{profile.photos.map((_, i) => (
<button
key={i}
onClick={() => goTo(i)}
className={`h-1 flex-1 rounded-(--radius-full) transition-colors ${
i === photoIndex ? "bg-white" : "bg-white/35"
}`}
/>
))}
</div>
)}
<button
onClick={() => router.back()}
className="absolute top-[calc(env(safe-area-inset-top)+16px)] right-4 w-9 h-9 rounded-full bg-black/30 backdrop-blur flex items-center justify-center"
>
<ChevronRight size={20} className="text-white" />
</button>
{profile.photos.length > 1 && (
<>
<button
aria-label="عکس قبلی"
onClick={() => goTo(photoIndex - 1)}
className="absolute inset-y-0 right-0 w-1/3"
/>
<button
aria-label="عکس بعدی"
onClick={() => goTo(photoIndex + 1)}
className="absolute inset-y-0 left-0 w-1/3"
/>
</>
)}
<div className="absolute -bottom-8 inset-x-0 flex items-center justify-center gap-5">
<motion.button
whileTap={{ scale: 0.85 }}
disabled={acting}
onClick={() => handleSwipe("pass")}
className="w-14 h-14 rounded-full bg-white flex items-center justify-center shadow-(--shadow-float) text-(--color-status-error) disabled:opacity-50"
>
<X size={26} strokeWidth={2.5} />
</motion.button>
<motion.button
whileTap={{ scale: 0.85 }}
disabled={acting}
onClick={() => handleSwipe("superlike")}
className="w-9 h-9 rounded-full bg-white flex items-center justify-center shadow-(--shadow-float) text-(--color-pop-purple) disabled:opacity-50"
>
<Star size={16} strokeWidth={2.5} />
</motion.button>
<motion.button
whileTap={{ scale: 0.85 }}
disabled={acting}
onClick={() => handleSwipe("like")}
className="w-16 h-16 rounded-full bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-md) text-white disabled:opacity-50"
>
<Heart size={28} fill="white" />
</motion.button>
</div>
</div>
<div className="flex flex-col gap-4 px-5 pt-12 pb-8">
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">
{profile.first_name || "بدون نام"}, {profile.age}
</h1>
{profile.distance_km != null && (
<div className="flex items-center gap-1.5 -mt-2">
<MapPin size={14} className="text-(--color-content-tertiary)" />
<span className="text-[13px] text-(--color-content-secondary)">
{formatDistance(profile.distance_km)}
</span>
</div>
)}
{(profile.height_cm || profile.weight_kg) && (
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
<div className="flex gap-4 text-[14px]">
{profile.height_cm && (
<span className="flex items-center gap-1.5">
<Ruler size={14} className="text-(--color-brand-500)" />
{profile.height_cm} سانتی‌متر
</span>
)}
{profile.weight_kg && (
<span className="flex items-center gap-1.5">
<Weight size={14} className="text-(--color-brand-500)" />
{profile.weight_kg} کیلوگرم
</span>
)}
</div>
</SectionCard>
)}
{(profile.education || profile.field_of_study) && (
<SectionCard icon={<GraduationCap size={15} />} title="تحصیلات">
<p className="text-[14px] text-(--color-content-primary)">
{profile.education} {profile.field_of_study ? `· ${profile.field_of_study}` : ""}
</p>
</SectionCard>
)}
{!!profile.occupations?.length && (
<SectionCard icon={<Briefcase size={15} />} title="فعالیت‌ها">
<div className="flex flex-wrap gap-1.5">
{profile.occupations.map((t) => (
<Tag key={t} label={t} color="sky" />
))}
</div>
</SectionCard>
)}
{!!profile.important_in_relationship?.length && (
<SectionCard icon={<Heart size={15} />} title="توی رابطه براش مهمه">
<div className="flex flex-wrap gap-1.5">
{profile.important_in_relationship.map((t) => (
<Tag key={t} label={t} color="pink" />
))}
</div>
</SectionCard>
)}
{!!profile.interests?.length && (
<SectionCard icon={<Sparkles size={15} />} title="علاقه‌مندی‌ها">
<div className="flex flex-wrap gap-1.5">
{profile.interests.map((t) => (
<Tag key={t} label={t} color="yellow" />
))}
</div>
</SectionCard>
)}
{profile.looking_for && (
<SectionCard icon={<Search size={15} />} title="دنبال چی می‌گرده">
<p className="text-[14px] text-(--color-content-primary)">{profile.looking_for}</p>
</SectionCard>
)}
{!!profile.profile_answers?.length && (
<SectionCard icon={<Sparkles size={15} />} title="یکم از خودش">
<div className="flex flex-col gap-3">
{profile.profile_answers.map((a, i) => (
<div key={i}>
<p className="text-[12px] font-bold text-(--color-content-tertiary) mb-0.5">
{a.question}
</p>
<p className="text-[14px] text-(--color-content-primary)">{a.answer}</p>
</div>
))}
</div>
</SectionCard>
)}
{profile.photos.length > 1 && (
<div>
<h2 className="text-[13px] font-bold text-(--color-content-secondary) mb-2 px-1">گالری</h2>
<div className="grid grid-cols-2 gap-2.5">
{profile.photos.map((p, i) => (
<button
key={i}
onClick={() => {
setPhotoIndex(i);
setViewerOpen(true);
}}
className="aspect-square rounded-(--radius-md) overflow-hidden"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoUrl(p)} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
</div>
)}
</div>
<AnimatePresence>
{viewerOpen && photo && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black flex flex-col"
>
<button
onClick={() => setViewerOpen(false)}
className="absolute top-[calc(env(safe-area-inset-top)+16px)] left-4 z-10 w-9 h-9 rounded-full bg-white/10 flex items-center justify-center"
>
<X size={20} className="text-white" />
</button>
<div className="flex-1 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoUrl(photo)} alt="" className="max-h-full max-w-full object-contain" />
</div>
<div className="flex items-center justify-center gap-2 p-4 pb-[calc(env(safe-area-inset-bottom)+16px)]">
{profile.photos.map((_, i) => (
<button
key={i}
onClick={() => goTo(i)}
className={`h-1.5 rounded-(--radius-full) transition-all ${
i === photoIndex ? "w-6 bg-white" : "w-1.5 bg-white/40"
}`}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Lock, Heart } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { DiscoverCandidate, getDiscoverFeed, photoUrl } from "@/lib/api";
// چه کسایی لایکت کردن — این قابلیت نیاز به شمارش لایک‌های دریافتی داره که
// هنوز توی بک‌اند پیاده نشده. فعلاً فقط یه پیش‌نمایش تارشده از پیشنهادهای
// فعلی نشون داده می‌شه تا رابط کاربری آماده باشه.
export default function LikesYouPage() {
const router = useRouter();
const [preview, setPreview] = useState<DiscoverCandidate[] | null>(null);
useEffect(() => {
getDiscoverFeed()
.then((data) => setPreview(data.slice(0, 6)))
.catch(() => setPreview([]));
}, []);
return (
<div className="flex flex-col min-h-full">
<header className="flex items-center gap-3 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
<button
onClick={() => router.push("/home")}
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-[18px]">کسایی که لایکت کردن</h1>
</header>
{preview === null ? (
<FullScreenLoader />
) : (
<div className="grid grid-cols-3 gap-3 px-5">
{preview.map((p) => (
<div
key={p.id}
className="relative aspect-[3/4] rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary)"
>
{p.photos[0] && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl(p.photos[0])}
alt=""
className="w-full h-full object-cover blur-md scale-110"
/>
)}
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
<Lock size={20} className="text-white" />
</div>
</div>
))}
</div>
)}
<div className="flex-1 flex flex-col items-center justify-center text-center gap-4 px-8 pt-8">
<div className="w-16 h-16 rounded-(--radius-lg) bg-(--color-brand-50) flex items-center justify-center text-(--color-brand-500)">
<Heart size={28} fill="currentColor" />
</div>
<div className="flex flex-col gap-1.5">
<h3 className="font-[var(--font-display)] font-bold text-[17px]">
این بخش هنوز به‌طور کامل فعال نشده
</h3>
<p className="text-[13px] text-(--color-content-secondary) leading-6 max-w-xs">
شمارش دقیق لایک‌های دریافتی هنوز توی بک‌اند پیاده نشده. فعلاً می‌تونی توی
صفحه‌ی کشف، خودت پروفایل‌ها رو ببینی و لایک کنی.
</p>
</div>
<Button size="sm" onClick={() => router.push("/home")}>
برگرد به کشف
</Button>
</div>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Bell, HourglassIcon, XCircle, SlidersHorizontal, Heart } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { useAuth } from "@/lib/auth-context";
import { formatApiError } from "@/lib/hooks";
import { SwipeDeck } from "@/components/discover/SwipeDeck";
import { FiltersSheet, DiscoverFilters } from "@/components/discover/FiltersSheet";
import { DiscoverCandidate, getDiscoverFeed, updateLocation } from "@/lib/api";
const POLL_MS = 20_000;
const DEFAULT_FILTERS: DiscoverFilters = { maxDistance: 300, minAge: 18, maxAge: 45 };
function BrandHeader() {
return (
<header className="flex items-center px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
دلدل 💌
</span>
</header>
);
}
function PendingReview() {
const { refreshUser } = useAuth();
const [checking, setChecking] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
refreshUser().catch(() => {});
}, POLL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [refreshUser]);
async function handleCheck() {
setChecking(true);
try {
await refreshUser();
} finally {
setChecking(false);
}
}
return (
<div className="flex flex-col min-h-full">
<BrandHeader />
<div className="flex-1 flex flex-col items-center justify-center px-6">
<Badge tone="warning" className="mb-4">
در انتظار بررسی ⏳
</Badge>
<EmptyState
icon={<HourglassIcon size={32} />}
title="پروفایلت داره بررسی می‌شه"
description="یکی از ادمین‌های دلدل داره اطلاعاتت رو نگاه می‌کنه. معمولاً زیاد طول نمی‌کشه؛ به‌محض تایید، خودکار میای اینجا."
actionLabel={checking ? "در حال بررسی..." : "بررسی وضعیت"}
onAction={handleCheck}
/>
</div>
</div>
);
}
function Rejected({ reason }: { reason?: string }) {
const router = useRouter();
return (
<div className="flex flex-col min-h-full">
<BrandHeader />
<div className="flex-1 flex flex-col items-center justify-center px-6">
<Badge tone="error" className="mb-4">
نیاز به اصلاح ✋
</Badge>
<div className="flex flex-col items-center text-center gap-4 max-w-xs">
<div className="w-20 h-20 rounded-(--radius-xl) bg-red-50 flex items-center justify-center text-(--color-status-error)">
<XCircle size={32} />
</div>
<div className="flex flex-col gap-1.5">
<h3 className="font-[var(--font-display)] font-bold text-[18px] text-(--color-content-primary)">
این بار تایید نشدی
</h3>
{reason && (
<p className="text-[14px] text-(--color-content-secondary) leading-6">{reason}</p>
)}
</div>
<Button size="sm" onClick={() => router.push("/profile")}>
ویرایش پروفایل
</Button>
</div>
</div>
</div>
);
}
function DiscoverScreen() {
const router = useRouter();
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<DiscoverFilters>(DEFAULT_FILTERS);
const [profiles, setProfiles] = useState<DiscoverCandidate[] | null>(null);
const [error, setError] = useState<string | null>(null);
const locationRequested = useRef(false);
const isFirstFiltersRun = useRef(true);
const load = useCallback(async (f: DiscoverFilters) => {
setError(null);
try {
const data = await getDiscoverFeed({
maxDistanceKm: f.maxDistance,
minAge: f.minAge,
maxAge: f.maxAge,
});
setProfiles(data);
} catch (err) {
setError(formatApiError(err));
setProfiles([]);
}
}, []);
useEffect(() => {
// فقط بار اول موقعیت مکانی رو می‌گیریم؛ اگه کاربر اجازه نده، بدون فاصله ادامه می‌دیم
if (!locationRequested.current && typeof navigator !== "undefined" && navigator.geolocation) {
locationRequested.current = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
updateLocation(pos.coords.latitude, pos.coords.longitude)
.catch(() => {})
.finally(() => load(filters));
},
() => load(filters),
{ timeout: 5000 }
);
} else {
load(filters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (isFirstFiltersRun.current) {
isFirstFiltersRun.current = false;
return;
}
load(filters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters]);
const key = useMemo(() => JSON.stringify(filters), [filters]);
return (
<div className="flex flex-col min-h-full">
<header className="flex items-center justify-between px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
دلدل 💌
</span>
<div className="flex items-center gap-1">
<button
onClick={() => router.push("/home/likes")}
className="flex items-center justify-center w-9 h-9 rounded-full bg-(--color-surface-interactive) text-(--color-brand-600)"
>
<Heart size={16} fill="currentColor" />
</button>
<button
onClick={() => setFiltersOpen(true)}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
>
<SlidersHorizontal size={18} className="text-(--color-content-secondary)" />
</button>
<button className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)">
<Bell size={19} className="text-(--color-content-secondary)" />
</button>
</div>
</header>
{profiles === null ? (
<FullScreenLoader label="داریم پیشنهاد میاریم..." />
) : error && profiles.length === 0 ? (
<div className="px-6 pt-4">
<ErrorBanner message={error} />
</div>
) : profiles.length === 0 ? (
<div className="flex-1 flex items-center justify-center px-6">
<EmptyState
icon={<SlidersHorizontal size={32} />}
title="کسی با این فیلترها پیدا نشد"
description="محدوده‌ی فاصله یا سن رو بازتر کن، یا بعداً دوباره سر بزن."
actionLabel="بازتنظیم فیلترها"
onAction={() => setFilters(DEFAULT_FILTERS)}
/>
</div>
) : (
<SwipeDeck key={key} profiles={profiles} onReload={() => load(filters)} />
)}
<FiltersSheet
open={filtersOpen}
initial={filters}
onClose={() => setFiltersOpen(false)}
onApply={setFilters}
/>
</div>
);
}
export default function HomePage() {
const { user } = useAuth();
if (user?.approval_status === "rejected") {
return <Rejected reason={user.rejection_reason} />;
}
if (user?.approval_status === "approved") {
return <DiscoverScreen />;
}
return <PendingReview />;
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { ReactNode, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { BottomNav } from "@/components/BottomNav";
import { stepByOrder } from "@/lib/onboarding-steps";
export default function MainLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const { status, user } = useAuth();
useEffect(() => {
if (status === "guest") {
router.replace("/login");
return;
}
if (status === "authenticated" && user && !user.is_onboarded) {
const slug = stepByOrder(user.current_step)?.slug ?? "basic-info";
router.replace(`/onboarding/${slug}`);
}
}, [status, user, router]);
if (status !== "authenticated" || !user?.is_onboarded) {
return <FullScreenLoader />;
}
return (
<div className="flex flex-col flex-1 min-h-dvh">
<div className="flex-1 overflow-y-auto">{children}</div>
<BottomNav />
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { ChevronRight, Send, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatApiError } from "@/lib/hooks";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import {
MessageView,
MatchView,
listMatches,
listMessages,
sendMessage,
photoUrl,
} from "@/lib/api";
const POLL_MS = 4000;
export default function ChatPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const matchId = Number(params.id);
const [match, setMatch] = useState<MatchView | null>(null);
const [messages, setMessages] = useState<MessageView[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refreshMessages = useCallback(async () => {
try {
const msgs = await listMessages(matchId);
setMessages(msgs);
} catch (err) {
setError(formatApiError(err));
}
}, [matchId]);
useEffect(() => {
listMatches()
.then((matches) => {
const found = matches.find((m) => m.id === matchId);
setMatch(found ?? null);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshMessages();
pollRef.current = setInterval(refreshMessages, POLL_MS);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [matchId, refreshMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [messages?.length]);
async function handleSend() {
const text = draft.trim();
if (!text || sending) return;
setSending(true);
setDraft("");
try {
const msg = await sendMessage(matchId, text);
setMessages((prev) => (prev ? [...prev, msg] : [msg]));
} catch (err) {
setError(formatApiError(err));
} finally {
setSending(false);
}
}
if (error && !messages) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
<ErrorBanner message={error} />
<button
onClick={() => router.push("/messages")}
className="text-[13px] font-bold text-(--color-brand-600)"
>
برگرد به پیام‌ها
</button>
</div>
);
}
if (!messages) return <FullScreenLoader />;
return (
<div className="flex flex-col min-h-full">
<header className="flex items-center gap-3 px-4 pt-[calc(env(safe-area-inset-top)+12px)] pb-3 border-b border-(--color-border-subtle) sticky top-0 bg-(--color-surface-default) z-10">
<button
onClick={() => router.push("/messages")}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) shrink-0"
>
<ChevronRight size={20} className="text-(--color-content-secondary)" />
</button>
<button
onClick={() => match && router.push(`/home/${match.user_id}`)}
className="flex items-center gap-2.5 flex-1 min-w-0"
>
<div className="w-10 h-10 rounded-full overflow-hidden bg-(--color-bg-tertiary) flex items-center justify-center shrink-0">
{match?.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photoUrl(match.photos[0])} alt={match.first_name} className="w-full h-full object-cover" />
) : (
<User size={18} className="text-(--color-content-disabled)" />
)}
</div>
<p className="font-bold text-[15px] text-(--color-content-primary) truncate">
{match?.first_name ?? "..."}
</p>
</button>
</header>
<div className="flex-1 flex flex-col gap-3 px-4 py-4 overflow-y-auto">
{messages.length === 0 ? (
<p className="text-[13px] text-(--color-content-tertiary) text-center py-8">
هنوز پیامی نیست؛ اولین نفر باش که سلام می‌ده 👋
</p>
) : (
messages.map((m) => (
<div
key={m.id}
className={cn("flex flex-col max-w-[75%]", m.is_mine ? "self-start items-start" : "self-end items-end")}
>
<div
className={cn(
"px-4 py-2.5 text-[14px] leading-6",
m.is_mine
? "bg-(--color-brand-500) text-white rounded-2xl rounded-br-md"
: "bg-(--color-bg-secondary) text-(--color-content-primary) rounded-2xl rounded-bl-md"
)}
>
{m.text}
</div>
</div>
))
)}
<div ref={bottomRef} />
</div>
<ErrorBanner message={error} />
<div className="flex items-center gap-2 px-4 py-3 border-t border-(--color-border-subtle) pb-[calc(env(safe-area-inset-bottom)+12px)]">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="پیامت رو بنویس..."
className="flex-1 h-11 rounded-(--radius-full) bg-(--color-bg-secondary) px-4 text-[14px] focus:outline-none focus:ring-2 focus:ring-(--color-brand-200)"
/>
<button
onClick={handleSend}
disabled={!draft.trim() || sending}
className="w-11 h-11 rounded-full bg-(--color-brand-500) text-white flex items-center justify-center disabled:opacity-40 shrink-0"
>
<Send size={17} />
</button>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { MessageCircleHeart, User } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { formatApiError } from "@/lib/hooks";
import { MatchView, listMatches, photoUrl } from "@/lib/api";
export default function MessagesPage() {
const router = useRouter();
const [matches, setMatches] = useState<MatchView[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
listMatches()
.then(setMatches)
.catch((err) => setError(formatApiError(err)));
}, []);
if (error && !matches) {
return (
<div className="p-6">
<ErrorBanner message={error} />
</div>
);
}
if (!matches) return <FullScreenLoader />;
const newMatches = matches.filter((m) => !m.last_message);
const conversations = matches.filter((m) => !!m.last_message);
return (
<div className="flex flex-col min-h-full">
<header className="px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-3">
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">پیام‌ها 💬</h1>
</header>
{newMatches.length > 0 && (
<div className="pb-2">
<h2 className="text-[13px] font-bold text-(--color-content-secondary) px-5 mb-3">
مچ‌های جدید
</h2>
<div className="flex gap-4 px-5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{newMatches.map((m) => (
<button
key={m.id}
onClick={() => router.push(`/messages/${m.id}`)}
className="flex flex-col items-center gap-1.5 shrink-0"
>
<div className="w-16 h-16 rounded-full p-[2.5px] bg-gradient-to-tr from-(--color-brand-500) to-(--color-pop-yellow)">
<div className="w-full h-full rounded-full border-2 border-(--color-surface-default) overflow-hidden bg-(--color-bg-tertiary) flex items-center justify-center">
{m.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl(m.photos[0])}
alt={m.first_name}
className="w-full h-full object-cover"
/>
) : (
<User size={22} className="text-(--color-content-disabled)" />
)}
</div>
</div>
<span className="text-[11px] font-semibold text-(--color-content-primary)">
{m.first_name}
</span>
</button>
))}
</div>
</div>
)}
<div className="pt-2 flex-1">
<h2 className="text-[13px] font-bold text-(--color-content-secondary) px-5 mb-2">
گفتگوها
</h2>
{matches.length === 0 ? (
<EmptyState
icon={<MessageCircleHeart size={32} />}
title="هنوز مچی نداری"
description="وقتی با کسی مچ بشی، می‌تونی از همینجا باهاش گفتگو رو شروع کنی."
/>
) : conversations.length === 0 ? (
<p className="text-[13px] text-(--color-content-tertiary) text-center py-8 px-6">
هنوز پیامی رد و بدل نشده؛ از بالا روی یکی از مچ‌ها بزن و شروع کن.
</p>
) : (
<div className="flex flex-col">
{conversations.map((c) => (
<button
key={c.id}
onClick={() => router.push(`/messages/${c.id}`)}
className="flex items-center gap-3 px-5 py-3 hover:bg-(--color-bg-secondary) text-right"
>
<div className="w-14 h-14 rounded-full overflow-hidden bg-(--color-bg-tertiary) flex items-center justify-center shrink-0">
{c.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl(c.photos[0])}
alt={c.first_name}
className="w-full h-full object-cover"
/>
) : (
<User size={22} className="text-(--color-content-disabled)" />
)}
</div>
<div className="flex-1 min-w-0">
<span className="font-bold text-[14px] text-(--color-content-primary)">
{c.first_name}
</span>
<p className="text-[13px] truncate mt-0.5 text-(--color-content-tertiary)">
{c.last_message}
</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
Pencil,
Ruler,
Weight,
GraduationCap,
Briefcase,
Heart,
Sparkles,
Search,
Plus,
X,
User as UserIcon,
} from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Tag } from "@/components/ui/Badge";
import { Avatar } from "@/components/ui/Avatar";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { getProfileMe, uploadPhoto, deletePhoto, photoUrl, FullProfile, Photo } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
import { calcAge } from "@/lib/utils";
function selectionTitles(profile: FullProfile, key: string): string[] {
return profile.selections.find((s) => s.category_key === key)?.titles ?? [];
}
function SectionCard({
icon,
title,
editHref,
children,
}: {
icon: React.ReactNode;
title: string;
editHref: string;
children: React.ReactNode;
}) {
return (
<Card className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-(--color-content-secondary)">
{icon}
<span className="text-[13px] font-bold">{title}</span>
</div>
<Link
href={editHref}
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
>
<Pencil size={13} />
</Link>
</div>
{children}
</Card>
);
}
const MAX_PHOTOS = 6;
function PhotosSection({ photos, onChange }: { photos: Photo[]; onChange: (photos: Photo[]) => void }) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
setError(null);
try {
const photo = await uploadPhoto(file);
onChange([...photos, photo]);
} catch (err) {
setError(formatApiError(err));
} finally {
setUploading(false);
}
}
async function handleDelete(id: number) {
setError(null);
try {
await deletePhoto(id);
onChange(photos.filter((p) => p.id !== id));
} catch (err) {
setError(formatApiError(err));
}
}
return (
<Card className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[13px] font-bold text-(--color-content-secondary)">
عکس‌های پروفایل
</span>
<span className="text-[11px] text-(--color-content-tertiary)">
{photos.length}/{MAX_PHOTOS}
</span>
</div>
<ErrorBanner message={error} />
<div className="grid grid-cols-3 gap-2.5">
{photos.map((p) => (
<div key={p.id} className="relative aspect-square rounded-(--radius-md) overflow-hidden group">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoUrl(p.url)} alt="" className="w-full h-full object-cover" />
<button
onClick={() => handleDelete(p.id)}
className="absolute top-1 left-1 w-6 h-6 rounded-full bg-black/50 flex items-center justify-center text-white"
aria-label="حذف عکس"
>
<X size={13} />
</button>
</div>
))}
{photos.length < MAX_PHOTOS && (
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="aspect-square rounded-(--radius-md) border-2 border-dashed border-(--color-border-default) flex items-center justify-center text-(--color-content-tertiary) disabled:opacity-50"
>
<Plus size={22} />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
</Card>
);
}
export default function ProfilePage() {
const [profile, setProfile] = useState<FullProfile | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getProfileMe()
.then(setProfile)
.catch((err) => setError(formatApiError(err)));
}, []);
if (error) {
return (
<div className="p-6">
<ErrorBanner message={error} />
</div>
);
}
if (!profile) return <FullScreenLoader />;
const { user } = profile;
const age = calcAge(user.birth_date);
const education = selectionTitles(profile, "education_level")[0];
const fieldOfStudy = selectionTitles(profile, "field_of_study")[0];
const occupations = selectionTitles(profile, "occupation");
const importance = selectionTitles(profile, "important_in_relationship");
const interests = selectionTitles(profile, "interest");
const lookingFor = selectionTitles(profile, "looking_for")[0];
const mainPhoto = profile.photos[0];
return (
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
<div className="flex flex-col items-center gap-3 pt-2 pb-2">
{mainPhoto ? (
<div className="w-16 h-16 rounded-full overflow-hidden shadow-(--shadow-cute-sm)">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoUrl(mainPhoto.url)} alt="" className="w-full h-full object-cover" />
</div>
) : (
<Avatar name={user.first_name || user.phone} size="lg" />
)}
<div className="text-center">
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
{user.first_name || "بدون نام"}
{age ? `، ${age}` : ""}
</h1>
<p dir="ltr" className="text-[13px] text-(--color-content-tertiary)">
{user.phone}
</p>
</div>
</div>
{profile.photos.length === 0 && (
<div className="flex items-start gap-2 rounded-(--radius-sm) bg-amber-50 border-2 border-amber-100 text-amber-800 px-4 py-3 text-[12px] font-medium">
<UserIcon size={15} className="shrink-0 mt-0.5" />
<span>بدون عکس، توی صفحه‌ی دوستیابی کمتر دیده می‌شی. همین الان یکی اضافه کن.</span>
</div>
)}
<PhotosSection
photos={profile.photos}
onChange={(photos) => setProfile((p) => (p ? { ...p, photos } : p))}
/>
<SectionCard icon={<Ruler size={15} />} title="قد و وزن" editHref="/onboarding/body-info">
<div className="flex gap-4 text-[14px]">
<span className="flex items-center gap-1.5">
<Ruler size={14} className="text-(--color-brand-500)" />
{user.height_cm ?? "—"} سانتی‌متر
</span>
<span className="flex items-center gap-1.5">
<Weight size={14} className="text-(--color-brand-500)" />
{user.weight_kg ?? "—"} کیلوگرم
</span>
</div>
</SectionCard>
<SectionCard icon={<GraduationCap size={15} />} title="تحصیلات" editHref="/onboarding/education">
<p className="text-[14px] text-(--color-content-primary)">
{education ?? "—"} {fieldOfStudy ? `· ${fieldOfStudy}` : ""}
</p>
</SectionCard>
<SectionCard icon={<Briefcase size={15} />} title="فعالیت‌ها" editHref="/onboarding/occupations">
<div className="flex flex-wrap gap-1.5">
{occupations.length ? (
occupations.map((t) => <Tag key={t} label={t} color="sky" />)
) : (
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
)}
</div>
</SectionCard>
<SectionCard
icon={<Heart size={15} />}
title="توی رابطه برام مهمه"
editHref="/onboarding/relationship-importance"
>
<div className="flex flex-wrap gap-1.5">
{importance.length ? (
importance.map((t) => <Tag key={t} label={t} color="pink" />)
) : (
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
)}
</div>
</SectionCard>
<SectionCard icon={<Sparkles size={15} />} title="علاقه‌مندی‌ها" editHref="/onboarding/interests">
<div className="flex flex-wrap gap-1.5">
{interests.length ? (
interests.map((t) => <Tag key={t} label={t} color="yellow" />)
) : (
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
)}
</div>
</SectionCard>
<SectionCard icon={<Search size={15} />} title="دنبال چی می‌گردم" editHref="/onboarding/looking-for">
<p className="text-[14px] text-(--color-content-primary)">{lookingFor ?? "—"}</p>
</SectionCard>
{profile.profile_answers.length > 0 && (
<SectionCard icon={<Sparkles size={15} />} title="یکم از خودم" editHref="/onboarding/profile-questions">
<div className="flex flex-col gap-3">
{profile.profile_answers.map((a) => (
<div key={a.id}>
<p className="text-[12px] font-bold text-(--color-content-tertiary) mb-0.5">
{a.question?.text}
</p>
<p className="text-[14px] text-(--color-content-primary)">{a.answer}</p>
</div>
))}
</div>
</SectionCard>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
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: "راهنما و پشتیبانی" },
];
export default function SettingsPage() {
const router = useRouter();
const { user, logout } = useAuth();
function handleLogout() {
logout();
router.push("/login");
}
return (
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
<h1 className="font-[var(--font-display)] font-extrabold text-[22px] px-1">
تنظیمات ⚙️
</h1>
<Card className="flex items-center justify-between">
<div>
<p className="text-[13px] text-(--color-content-tertiary)">شماره موبایل</p>
<p dir="ltr" className="text-[15px] font-bold text-(--color-content-primary)">
{user?.phone}
</p>
</div>
</Card>
<Card padded={false} className="overflow-hidden">
{comingSoonItems.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)" : ""
}`}
>
<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>
);
})}
</Card>
<Button
variant="tertiary"
size="lg"
isBlock
icon={<LogOut size={17} />}
onClick={handleLogout}
className="!text-(--color-status-error) !border-red-200 mt-4"
>
خروج از حساب
</Button>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

+233
View File
@@ -0,0 +1,233 @@
@import "tailwindcss";
/* ============================================================
DELDEL DESIGN SYSTEM — نسخه‌ی هماهنگ‌شده با کیت Figma
(Dating app - Free UI Kit): صورتی-قرمز پرانرژی روی زمینه‌ی
سفید/خاکستری خیلی روشن، کارت‌های سفید با سایه‌ی نرم، دکمه‌های
کاملاً گرد (pill) و دکمه‌های دایره‌ای شناور برای اکشن‌های سواپ.
============================================================ */
:root {
/* -------- brand / accent (صورتی-قرمز اصلی کیت) -------- */
--color-brand-50: #fff0f3;
--color-brand-100: #ffdce3;
--color-brand-200: #ffb8c7;
--color-brand-300: #ff8ca3;
--color-brand-400: #ff6683;
--color-brand-500: #ff4d6d; /* صورتی-قرمز اصلی کیت */
--color-brand-600: #f22c55;
--color-brand-700: #d81b47;
--color-brand-800: #ad1338;
--color-brand-900: #7c0e29;
--color-brand-onPrimary: #ffffff;
/* -------- accent دوم (نقطه‌ها/برچسب‌ها/ستاره‌ی سوپرلایک) -------- */
--color-pop-yellow: #ffc93c;
--color-pop-pink: #ff8fb1;
--color-pop-mint: #3ddc97;
--color-pop-sky: #4fb4ff;
--color-pop-purple: #8b5cf6;
/* -------- neutral (پس‌زمینه‌ی سفید/خاکستری خیلی روشن مثل کیت) -------- */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f6f6f9;
--color-bg-tertiary: #eeeef2;
--color-surface-default: #ffffff;
--color-surface-elevated: #ffffff;
--color-surface-interactive: #fff0f3;
--color-content-primary: #16161d;
--color-content-secondary: #6f6f7d;
--color-content-tertiary: #a4a4b0;
--color-content-disabled: #d3d3da;
--color-content-onColor: #ffffff;
--color-border-subtle: #f0f0f4;
--color-border-default: #e3e3ea;
--color-border-interactive: var(--color-brand-500);
--color-status-success: #21a179;
--color-status-warning: #d98a12;
--color-status-error: #e0301e;
--color-status-info: #3d8fd6;
/* -------- radius: کارت‌های خیلی گرد + دکمه‌های کاملاً pill -------- */
--radius-xs: 10px;
--radius-sm: 14px;
--radius-md: 20px;
--radius-lg: 28px;
--radius-xl: 36px;
--radius-full: 999px;
/* -------- سایه‌های نرم صورتی (به‌جای بردر مسطح) -------- */
--shadow-cute-sm: 0 2px 8px -2px rgba(255, 77, 109, 0.18), 0 1px 2px rgba(22, 22, 29, 0.05);
--shadow-cute-md: 0 10px 24px -8px rgba(255, 77, 109, 0.24), 0 2px 6px rgba(22, 22, 29, 0.05);
--shadow-cute-lg: 0 20px 45px -12px rgba(255, 77, 109, 0.3), 0 4px 10px rgba(22, 22, 29, 0.07);
--shadow-pop-yellow: 0 8px 20px -6px rgba(255, 201, 60, 0.45);
--shadow-pop-mint: 0 8px 20px -6px rgba(61, 220, 151, 0.4);
--shadow-float: 0 6px 16px -4px rgba(22, 22, 29, 0.18);
--background: var(--color-bg-primary);
--foreground: var(--color-content-primary);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-brand-50: var(--color-brand-50);
--color-brand-100: var(--color-brand-100);
--color-brand-200: var(--color-brand-200);
--color-brand-300: var(--color-brand-300);
--color-brand-400: var(--color-brand-400);
--color-brand-500: var(--color-brand-500);
--color-brand-600: var(--color-brand-600);
--color-brand-700: var(--color-brand-700);
--color-brand-800: var(--color-brand-800);
--color-brand-900: var(--color-brand-900);
--color-pop-yellow: var(--color-pop-yellow);
--color-pop-pink: var(--color-pop-pink);
--color-pop-mint: var(--color-pop-mint);
--color-pop-sky: var(--color-pop-sky);
--color-pop-purple: var(--color-pop-purple);
--color-bg-primary: var(--color-bg-primary);
--color-bg-secondary: var(--color-bg-secondary);
--color-bg-tertiary: var(--color-bg-tertiary);
--color-surface-default: var(--color-surface-default);
--color-surface-elevated: var(--color-surface-elevated);
--color-surface-interactive: var(--color-surface-interactive);
--color-content-primary: var(--color-content-primary);
--color-content-secondary: var(--color-content-secondary);
--color-content-tertiary: var(--color-content-tertiary);
--color-content-disabled: var(--color-content-disabled);
--color-content-onColor: var(--color-content-onColor);
--color-border-subtle: var(--color-border-subtle);
--color-border-default: var(--color-border-default);
--color-border-interactive: var(--color-border-interactive);
--color-status-success: var(--color-status-success);
--color-status-warning: var(--color-status-warning);
--color-status-error: var(--color-status-error);
--color-status-info: var(--color-status-info);
--radius-xs: var(--radius-xs);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-full: var(--radius-full);
--shadow-cute-sm: var(--shadow-cute-sm);
--shadow-cute-md: var(--shadow-cute-md);
--shadow-cute-lg: var(--shadow-cute-lg);
--shadow-pop-yellow: var(--shadow-pop-yellow);
--shadow-pop-mint: var(--shadow-pop-mint);
--shadow-float: var(--shadow-float);
--font-sans: "Vazirmatn Variable", "Baloo 2 Variable", system-ui, sans-serif;
--font-display: "Baloo 2 Variable", "Vazirmatn Variable", system-ui, sans-serif;
}
* {
border-color: var(--color-border-subtle);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
::selection {
background: var(--color-brand-200);
color: var(--color-brand-900);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--color-brand-200);
border-radius: 999px;
}
::-webkit-scrollbar-track {
background: transparent;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
@keyframes bounce-in {
0% {
transform: scale(0.9) translateY(8px);
opacity: 0;
}
60% {
transform: scale(1.02) translateY(-2px);
opacity: 1;
}
100% {
transform: scale(1) translateY(0);
}
}
@keyframes wiggle {
0%,
100% {
transform: rotate(-2deg);
}
50% {
transform: rotate(2deg);
}
}
@keyframes heart-pop {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1.15);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes sheet-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 rgba(255, 77, 109, 0.35);
}
100% {
box-shadow: 0 0 0 14px rgba(255, 77, 109, 0);
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata, Viewport } from "next";
import "@fontsource-variable/vazirmatn";
import "@fontsource-variable/baloo-2";
import "./globals.css";
import { AuthProvider } from "@/lib/auth-context";
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
export const metadata: Metadata = {
title: "دلدل | Deldel",
description: "اپلیکیشن آشنایی و دوستیابی — شاد، گرد و صمیمی",
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "دلدل",
},
icons: {
icon: [
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: [{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#ff4433",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="fa" dir="rtl" className="h-full antialiased">
<body className="min-h-dvh flex flex-col bg-(--color-bg-primary) text-(--color-content-primary) overscroll-none">
<AuthProvider>
<div className="mx-auto w-full max-w-[480px] min-h-dvh flex flex-col relative bg-(--color-bg-primary) shadow-[0_0_60px_rgba(0,0,0,0.06)]">
{children}
</div>
</AuthProvider>
<ServiceWorkerRegister />
</body>
</html>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Smartphone } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { requestOtp } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
import { useAuth } from "@/lib/auth-context";
const PHONE_REGEX = /^09\d{9}$/;
export default function LoginPage() {
const router = useRouter();
const { status } = useAuth();
const [phone, setPhone] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (status === "authenticated") {
router.replace("/home");
}
}, [status, router]);
const isValid = PHONE_REGEX.test(phone);
async function handleSubmit() {
if (!isValid) return;
setLoading(true);
setError(null);
try {
await requestOtp(phone);
router.push(`/verify?phone=${phone}`);
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
return (
<div className="flex-1 flex flex-col min-h-dvh px-6 pt-[calc(env(safe-area-inset-top)+16px)] pb-[calc(env(safe-area-inset-bottom)+32px)]">
<button
onClick={() => router.push("/welcome")}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) self-start"
aria-label="بازگشت"
>
<ChevronRight size={20} className="text-(--color-content-secondary)" />
</button>
<div className="flex flex-col gap-2 pt-8">
<h1 className="font-[var(--font-display)] font-extrabold text-[28px] text-(--color-content-primary)">
شماره موبایلت 📱
</h1>
<p className="text-[14px] text-(--color-content-secondary) leading-7 max-w-xs">
شماره موبایل معتبرت رو وارد کن؛ یه کد ۴ رقمی برات می‌فرستیم تا حسابت رو تایید کنیم.
</p>
</div>
<div className="flex-1 flex flex-col justify-center gap-4">
<div
dir="ltr"
className={`flex items-center h-14 rounded-(--radius-md) bg-(--color-bg-secondary) border-2 transition-colors focus-within:border-(--color-border-interactive) focus-within:bg-(--color-surface-default) focus-within:ring-4 focus-within:ring-(--color-brand-100) ${
error ? "border-(--color-status-error)" : "border-transparent"
}`}
>
<div className="flex items-center gap-2 px-4 border-l-2 border-(--color-border-subtle) h-full shrink-0">
<span className="text-[18px] leading-none">🇮🇷</span>
<span className="text-[15px] font-bold text-(--color-content-secondary)">+98</span>
</div>
<div className="flex items-center gap-2 flex-1 px-4">
<Smartphone size={17} className="text-(--color-content-tertiary) shrink-0" />
<input
type="tel"
inputMode="numeric"
placeholder="09xxxxxxxxx"
className="flex-1 bg-transparent text-[16px] tracking-widest text-(--color-content-primary) placeholder:text-(--color-content-tertiary) focus:outline-none"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
autoFocus
/>
</div>
</div>
<ErrorBanner message={error} />
</div>
<div className="flex flex-col gap-4">
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
ادامه
</Button>
<p className="text-[11px] text-center text-(--color-content-tertiary) leading-6">
با ادامه دادن، قوانین و حریم خصوصی دلدل رو قبول می‌کنی.
</p>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { useRequireAuth } from "@/lib/hooks";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { OnboardingScreen } from "@/components/onboarding/OnboardingScreen";
import { BasicInfoStep } from "@/components/onboarding/steps/BasicInfoStep";
import { BodyInfoStep } from "@/components/onboarding/steps/BodyInfoStep";
import { ProfileQuestionsStep } from "@/components/onboarding/steps/ProfileQuestionsStep";
import { EducationStep } from "@/components/onboarding/steps/EducationStep";
import { GenericOptionStep } from "@/components/onboarding/steps/GenericOptionStep";
import {
stepBySlug,
nextStepSlug,
} from "@/lib/onboarding-steps";
import { getProfileMe, FullProfile, submitOccupations, submitRelationshipImportance, submitInterests, submitLookingFor } from "@/lib/api";
function slugFromKey(key: string) {
return key.replace(/_/g, "-");
}
export default function OnboardingStepPage() {
const params = useParams<{ slug: string }>();
const slug = params.slug;
const router = useRouter();
const { status } = useRequireAuth();
const { onboardingStatus, refreshOnboardingStatus } = useAuth();
const [profile, setProfile] = useState<FullProfile | null>(null);
const [guardChecked, setGuardChecked] = useState(false);
const meta = stepBySlug(slug);
const loadProfile = useCallback(async () => {
try {
const p = await getProfileMe();
setProfile(p);
} catch {
// اگه خطا خورد، فرم‌ها بدون prefill نمایش داده می‌شن
}
}, []);
useEffect(() => {
if (status !== "authenticated" || !meta) return;
let cancelled = false;
(async () => {
const s = onboardingStatus ?? (await refreshOnboardingStatus());
if (cancelled) return;
if (!s) {
setGuardChecked(true);
return;
}
const currentOrder =
s.steps.find((st) => st.key === s.current_step_key)?.step ?? meta.order;
// اگه هنوز onboarded نشده و می‌خواد از مرحله‌ی فعلی جلوتر بره، برش‌گردون
if (!s.is_onboarded && meta.order > currentOrder) {
const correctSlug = s.steps.find((st) => st.step === currentOrder);
router.replace(`/onboarding/${correctSlug ? slugFromKey(correctSlug.key) : ""}`);
return;
}
await loadProfile();
setGuardChecked(true);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, slug]);
const goNext = useCallback(async () => {
await refreshOnboardingStatus();
const next = nextStepSlug(slug);
if (next) {
router.push(`/onboarding/${next}`);
} else {
router.push("/onboarding/done");
}
}, [slug, router, refreshOnboardingStatus]);
if (status !== "authenticated" || !meta || !guardChecked) {
return <FullScreenLoader />;
}
return (
<OnboardingScreen
order={meta.order}
title={meta.title}
emoji={meta.emoji}
onBack={meta.order === 1 ? () => router.push("/login") : undefined}
>
{renderStep(slug, profile, goNext)}
</OnboardingScreen>
);
}
function renderStep(slug: string, profile: FullProfile | null, onNext: () => void) {
switch (slug) {
case "basic-info":
return <BasicInfoStep initialProfile={profile} onNext={onNext} />;
case "body-info":
return <BodyInfoStep initialProfile={profile} onNext={onNext} />;
case "profile-questions":
return <ProfileQuestionsStep initialProfile={profile} onNext={onNext} />;
case "education":
return <EducationStep initialProfile={profile} onNext={onNext} />;
case "occupations":
return (
<GenericOptionStep
initialProfile={profile}
onNext={onNext}
category="occupation"
mode="multi"
min={1}
submitFn={submitOccupations}
helperText="هرچی که به کارت میاد رو انتخاب کن"
emptyMessage="هنوز گزینه‌ای برای فعالیت‌ها ثبت نشده."
/>
);
case "relationship-importance":
return (
<GenericOptionStep
initialProfile={profile}
onNext={onNext}
category="important_in_relationship"
mode="multi"
min={3}
max={3}
submitFn={submitRelationshipImportance}
helperText="دقیقاً ۳ تا انتخاب کن"
emptyMessage="هنوز گزینه‌ای ثبت نشده."
/>
);
case "interests":
return (
<GenericOptionStep
initialProfile={profile}
onNext={onNext}
category="interest"
mode="multi"
min={5}
max={5}
submitFn={submitInterests}
helperText="دقیقاً ۵ تا انتخاب کن"
emptyMessage="هنوز گزینه‌ای ثبت نشده."
/>
);
case "looking-for":
return (
<GenericOptionStep
initialProfile={profile}
onNext={onNext}
category="looking_for"
mode="single"
min={1}
max={1}
submitFn={submitLookingFor}
emptyMessage="هنوز گزینه‌ای ثبت نشده."
/>
);
default:
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { PartyPopper } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { useAuth } from "@/lib/auth-context";
import { useRequireAuth } from "@/lib/hooks";
export default function OnboardingDonePage() {
const router = useRouter();
const { status } = useRequireAuth();
const { onboardingStatus, refreshOnboardingStatus, refreshUser } = useAuth();
const [checked, setChecked] = useState(false);
useEffect(() => {
if (status !== "authenticated") return;
(async () => {
const s = onboardingStatus ?? (await refreshOnboardingStatus());
if (!s?.is_onboarded) {
router.replace("/onboarding/basic-info");
return;
}
// user توی context از قبل onboarding ممکنه هنوز is_onboarded=false داشته باشه
// (هیچ مرحله‌ای توی خود فرآیند onboarding این رو رفرش نمی‌کنه)؛ بدون این،
// MainLayout با state قدیمی دوباره برمی‌گردونه به یکی از مراحل onboarding.
await refreshUser();
setChecked(true);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]);
if (status !== "authenticated" || !checked) return <FullScreenLoader />;
return (
<div className="flex-1 flex flex-col items-center justify-center text-center gap-6 px-8 min-h-dvh">
<div className="w-24 h-24 rounded-(--radius-xl) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-lg) [animation:bounce-in_0.5s_ease-out]">
<PartyPopper size={40} className="text-white" />
</div>
<div className="flex flex-col gap-2">
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
پروفایلت کامل شد! 🎉
</h1>
<p className="text-[14px] text-(--color-content-secondary) leading-7 max-w-xs">
پروفایلت رفت برای بررسی ادمین. به‌محض تایید، خودکار می‌تونی افراد جدید رو
کشف کنی. هر وقت خواستی هم می‌تونی از پروفایلت هرکدوم از این اطلاعات رو
ویرایش کنی.
</p>
</div>
<Button size="lg" isBlock onClick={() => router.push("/home")}>
بزن بریم 🚀
</Button>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { stepByOrder } from "@/lib/onboarding-steps";
export default function RootPage() {
const router = useRouter();
const { status, user } = useAuth();
useEffect(() => {
if (status === "loading") return;
if (status === "guest") {
router.replace("/welcome");
return;
}
if (user) {
if (user.is_onboarded) {
router.replace("/home");
} else {
const slug = stepByOrder(user.current_step)?.slug ?? "basic-info";
router.replace(`/onboarding/${slug}`);
}
}
}, [status, user, router]);
return <FullScreenLoader label="دلدل داره باز می‌شه..." />;
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { OtpInput } from "@/components/ui/OtpInput";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { requestOtp } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
import { useAuth } from "@/lib/auth-context";
import { stepByOrder } from "@/lib/onboarding-steps";
const RESEND_SECONDS = 60;
function formatCountdown(seconds: number) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function VerifyForm() {
const router = useRouter();
const searchParams = useSearchParams();
const phone = searchParams.get("phone") ?? "";
const { login } = useAuth();
const [otp, setOtp] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [countdown, setCountdown] = useState(RESEND_SECONDS);
const [resending, setResending] = useState(false);
useEffect(() => {
if (countdown <= 0) return;
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(t);
}, [countdown]);
useEffect(() => {
if (!phone) router.replace("/login");
}, [phone, router]);
async function handleVerify(code: string) {
if (code.length !== 4) return;
setLoading(true);
setError(null);
try {
const user = await login(phone, code);
if (user.is_onboarded) {
router.push("/home");
} else {
const stepSlug = stepByOrder(user.current_step)?.slug ?? "basic-info";
router.push(`/onboarding/${stepSlug}`);
}
} catch (err) {
setError(formatApiError(err, "کد وارد شده اشتباهه"));
setOtp("");
} finally {
setLoading(false);
}
}
async function handleResend() {
setResending(true);
setError(null);
try {
await requestOtp(phone);
setCountdown(RESEND_SECONDS);
} catch (err) {
setError(formatApiError(err));
} finally {
setResending(false);
}
}
return (
<div className="flex-1 flex flex-col min-h-dvh px-6 pt-[calc(env(safe-area-inset-top)+16px)] pb-[calc(env(safe-area-inset-bottom)+32px)]">
<button
onClick={() => router.push("/login")}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) self-start"
aria-label="بازگشت"
>
<ChevronRight size={20} className="text-(--color-content-secondary)" />
</button>
<div className="flex flex-col items-center text-center gap-2 pt-6">
<span
dir="ltr"
className={`font-[var(--font-display)] font-extrabold text-[44px] tabular-nums tracking-wide ${
countdown > 0 ? "text-(--color-brand-500)" : "text-(--color-content-disabled)"
}`}
>
{formatCountdown(countdown)}
</span>
<h1 className="font-[var(--font-display)] font-extrabold text-[20px] pt-2">
کد تایید رو وارد کن
</h1>
<p className="text-[14px] text-(--color-content-secondary)">
کد ۴ رقمی ارسال‌شده به{" "}
<span dir="ltr" className="font-bold text-(--color-content-primary)">
{phone}
</span>
</p>
</div>
<div className="flex flex-col gap-6 pt-10">
<OtpInput value={otp} onChange={setOtp} onComplete={handleVerify} disabled={loading} />
<ErrorBanner message={error} />
<Button
size="lg"
isBlock
isLoading={loading}
disabled={otp.length !== 4}
onClick={() => handleVerify(otp)}
>
تایید کد
</Button>
<div className="flex items-center justify-center gap-1.5 text-[13px]">
<span className="text-(--color-content-tertiary)">کد نیومد؟</span>
{countdown > 0 ? (
<span className="font-bold text-(--color-content-secondary)">صبر کن...</span>
) : (
<button
onClick={handleResend}
disabled={resending}
className="font-bold text-(--color-brand-600) hover:text-(--color-brand-700) disabled:opacity-50"
>
دوباره بفرست
</button>
)}
</div>
</div>
</div>
);
}
export default function VerifyPage() {
return (
<Suspense>
<VerifyForm />
</Suspense>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { ShieldCheck, Sparkles, Heart } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/utils";
interface Slide {
icon: React.ReactNode;
gradient: string;
title: string;
description: string;
}
const SLIDES: Slide[] = [
{
icon: <ShieldCheck size={64} className="text-white" strokeWidth={1.75} />,
gradient: "linear-gradient(160deg, #ff8ca3 0%, #ff4d6d 55%, #d81b47 100%)",
title: "پروفایل‌های تایید شده",
description:
"قبل از این‌که کسی وارد بخش دوستیابی بشه، تیم دلدل پروفایلش رو بررسی می‌کنه؛ یعنی خبری از ربات و پروفایل‌های تقلبی نیست.",
},
{
icon: <Sparkles size={64} className="text-white" strokeWidth={1.75} />,
gradient: "linear-gradient(160deg, #ffc93c 0%, #ff8ca3 55%, #ff4d6d 100%)",
title: "مچ‌های واقعی",
description:
"بر اساس علاقه‌مندی‌ها، سبک زندگی و چیزهایی که برات مهمه، با آدم‌های مناسب‌تر آشنا می‌شی.",
},
{
icon: <Heart size={64} className="text-white" fill="white" strokeWidth={0} />,
gradient: "linear-gradient(160deg, #8b5cf6 0%, #ff4d6d 55%, #ff8ca3 100%)",
title: "دوستیابی، ساده و امن",
description:
"یکی‌یکی پروفایل‌ها رو ببین، اگه خوشت اومد لایک کن، نه که نه؛ همه‌چیز دست خودته.",
},
];
export default function WelcomePage() {
const router = useRouter();
const scrollerRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
function onScroll() {
if (!el) return;
const index = Math.round(el.scrollLeft / el.clientWidth);
setActive((prev) => (prev === index ? prev : index));
}
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
function goToSlide(index: number) {
const el = scrollerRef.current;
if (!el) return;
el.scrollTo({ left: index * el.clientWidth, behavior: "smooth" });
}
return (
<div className="flex flex-col min-h-dvh bg-(--color-bg-primary)">
<div
ref={scrollerRef}
dir="ltr"
className="flex-1 flex overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{SLIDES.map((slide, i) => (
<div
key={i}
dir="rtl"
className="w-full shrink-0 snap-center flex flex-col items-center px-8 pt-[calc(env(safe-area-inset-top)+32px)]"
>
<motion.div
initial={{ opacity: 0, scale: 0.85, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="w-full aspect-square max-w-[280px] rounded-(--radius-xl) flex items-center justify-center shadow-(--shadow-cute-lg) mt-6"
style={{ background: slide.gradient }}
>
{slide.icon}
</motion.div>
<div className="flex flex-col items-center text-center gap-3 mt-10 max-w-xs">
<h1 className="font-[var(--font-display)] font-extrabold text-[26px] text-(--color-content-primary)">
{slide.title}
</h1>
<p className="text-[14px] text-(--color-content-secondary) leading-7">
{slide.description}
</p>
</div>
</div>
))}
</div>
<div className="flex flex-col gap-6 px-8 pb-[calc(env(safe-area-inset-bottom)+28px)] pt-4">
<div className="flex items-center justify-center gap-2">
{SLIDES.map((_, i) => (
<button
key={i}
aria-label={`اسلاید ${i + 1}`}
onClick={() => goToSlide(i)}
className={cn(
"h-2 rounded-(--radius-full) transition-all duration-300",
i === active
? "w-6 bg-(--color-brand-500)"
: "w-2 bg-(--color-bg-tertiary)"
)}
/>
))}
</div>
<Button size="lg" isBlock onClick={() => router.push("/login")}>
بساز حساب کاربری
</Button>
<button
onClick={() => router.push("/login")}
className="text-[13px] text-center text-(--color-content-secondary)"
>
قبلاً حساب داری؟ <span className="font-bold text-(--color-brand-600)">ورود</span>
</button>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Flame, MessageCircle, User, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
const items = [
{ href: "/home", label: "کشف", icon: Flame },
{ href: "/messages", label: "پیام‌ها", icon: MessageCircle },
{ href: "/profile", label: "پروفایل", icon: User },
{ href: "/settings", label: "تنظیمات", icon: Settings },
];
export function BottomNav() {
const pathname = usePathname();
return (
<nav className="sticky bottom-0 inset-x-0 bg-(--color-surface-default) border-t border-(--color-border-subtle) pb-[env(safe-area-inset-bottom)] shadow-(--shadow-cute-md) z-30">
<div className="flex items-center justify-around h-16">
{items.map((item) => {
const active = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className="flex flex-col items-center gap-1 flex-1 py-1.5"
>
<div
className={cn(
"w-12 h-8 rounded-(--radius-full) flex items-center justify-center transition-all duration-200",
active && "bg-(--color-brand-100)"
)}
>
<Icon
size={19}
className={active ? "text-(--color-brand-600)" : "text-(--color-content-tertiary)"}
strokeWidth={active ? 2.5 : 2}
/>
</div>
<span
className={cn(
"text-[10px] font-bold",
active ? "text-(--color-brand-600)" : "text-(--color-content-tertiary)"
)}
>
{item.label}
</span>
</Link>
);
})}
</div>
</nav>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegister() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {
// نصب PWA اختیاریه؛ اگه شکست خورد اپ عادی کار می‌کنه
});
}
}, []);
return null;
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X } from "lucide-react";
import { Button } from "@/components/ui/Button";
export interface DiscoverFilters {
maxDistance: number;
minAge: number;
maxAge: number;
}
export function FiltersSheet({
open,
initial,
onClose,
onApply,
}: {
open: boolean;
initial: DiscoverFilters;
onClose: () => void;
onApply: (filters: DiscoverFilters) => void;
}) {
const [maxDistance, setMaxDistance] = useState(initial.maxDistance);
const [minAge, setMinAge] = useState(initial.minAge);
const [maxAge, setMaxAge] = useState(initial.maxAge);
return (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
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-5 pb-[calc(env(safe-area-inset-bottom)+24px)]"
>
<div className="flex items-center justify-between mb-6">
<h2 className="font-[var(--font-display)] font-extrabold text-[19px]">فیلترها</h2>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
>
<X size={18} />
</button>
</div>
<div className="flex flex-col gap-6">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] font-bold text-(--color-content-primary)">
حداکثر فاصله
</span>
<span className="text-[13px] font-bold text-(--color-brand-600)">
{maxDistance} کیلومتر
</span>
</div>
<input
type="range"
min={1}
max={300}
value={maxDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))}
className="w-full accent-(--color-brand-500)"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] font-bold text-(--color-content-primary)">
محدوده‌ی سنی
</span>
<span className="text-[13px] font-bold text-(--color-brand-600)">
{minAge} تا {maxAge} سال
</span>
</div>
<div className="flex flex-col gap-3">
<input
type="range"
min={18}
max={maxAge}
value={minAge}
onChange={(e) => setMinAge(Math.min(Number(e.target.value), maxAge))}
className="w-full accent-(--color-brand-500)"
/>
<input
type="range"
min={minAge}
max={60}
value={maxAge}
onChange={(e) => setMaxAge(Math.max(Number(e.target.value), minAge))}
className="w-full accent-(--color-brand-500)"
/>
</div>
</div>
</div>
<Button
size="lg"
isBlock
className="mt-8"
onClick={() => {
onApply({ maxDistance, minAge, maxAge });
onClose();
}}
>
اعمال فیلتر
</Button>
</motion.div>
</>
)}
</AnimatePresence>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { User } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { MatchView, photoUrl } from "@/lib/api";
export function MatchModal({
match,
onClose,
}: {
match: MatchView | null;
onClose: () => void;
}) {
const router = useRouter();
const photo = match?.photos[0];
return (
<AnimatePresence>
{match && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/70 px-8 text-center"
>
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 18, delay: 0.1 }}
className="w-28 h-28 rounded-full border-4 border-white overflow-hidden shadow-(--shadow-cute-lg) mb-6 bg-(--color-bg-tertiary) flex items-center justify-center"
>
{photo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photoUrl(photo)} alt={match.first_name} className="w-full h-full object-cover" />
) : (
<User size={40} className="text-(--color-content-disabled)" />
)}
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="font-[var(--font-display)] font-extrabold text-[30px] text-white"
>
یه مچ جدید داری، {match.first_name}! 🎉
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-[14px] text-white/80 mt-2 max-w-xs"
>
هردوتون همدیگه رو لایک کردین. الان وقت خوبیه که یه سلام بدی.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="flex flex-col gap-3 w-full max-w-xs mt-10"
>
<Button
size="lg"
isBlock
onClick={() => {
onClose();
router.push(`/messages/${match.id}`);
}}
>
سلام بده 👋
</Button>
<button
onClick={onClose}
className="h-12 text-[14px] font-bold text-white/80 hover:text-white"
>
به کشف کردن ادامه بده
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useRouter } from "next/navigation";
import { motion, useMotionValue, useTransform, PanInfo } from "framer-motion";
import { MapPin, User } from "lucide-react";
import { DiscoverCandidate, photoUrl } from "@/lib/api";
import { formatDistance } from "@/lib/utils";
const SWIPE_THRESHOLD = 120;
export function SwipeCard({
profile,
isTop,
stackIndex,
onDecide,
}: {
profile: DiscoverCandidate;
isTop: boolean;
stackIndex: number;
onDecide: (direction: "left" | "right") => void;
}) {
const router = useRouter();
const x = useMotionValue(0);
const rotate = useTransform(x, [-260, 260], [-16, 16]);
const likeOpacity = useTransform(x, [20, 120], [0, 1]);
const nopeOpacity = useTransform(x, [-120, -20], [1, 0]);
function handleDragEnd(_: unknown, info: PanInfo) {
if (info.offset.x > SWIPE_THRESHOLD) {
onDecide("right");
} else if (info.offset.x < -SWIPE_THRESHOLD) {
onDecide("left");
}
}
const photo = profile.photos[0];
return (
<motion.div
className="absolute inset-0"
style={
isTop
? { x, rotate }
: {
scale: 1 - stackIndex * 0.04,
y: stackIndex * 10,
}
}
drag={isTop ? "x" : false}
dragElastic={0.9}
onDragEnd={isTop ? handleDragEnd : undefined}
initial={{ scale: 1 - stackIndex * 0.04, y: stackIndex * 10, opacity: 0 }}
animate={{ scale: 1 - stackIndex * 0.04, y: stackIndex * 10, opacity: 1 }}
exit={{
x: x.get() > 0 ? 400 : x.get() < 0 ? -400 : 0,
opacity: 0,
transition: { duration: 0.3 },
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div
onClick={() => isTop && router.push(`/home/${profile.id}`)}
className="relative w-full h-full rounded-(--radius-xl) overflow-hidden shadow-(--shadow-cute-lg) bg-(--color-bg-tertiary) cursor-pointer select-none"
>
{photo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl(photo)}
alt={profile.first_name}
draggable={false}
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-(--color-bg-tertiary) text-(--color-content-disabled) pointer-events-none">
<User size={64} strokeWidth={1.2} />
</div>
)}
<div className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none" />
{isTop && (
<>
<motion.span
style={{ opacity: likeOpacity }}
className="absolute top-8 right-6 rotate-[-12deg] border-4 border-(--color-status-success) text-(--color-status-success) font-[var(--font-display)] font-extrabold text-[26px] px-3 py-1 rounded-(--radius-sm) bg-white/70"
>
لایک
</motion.span>
<motion.span
style={{ opacity: nopeOpacity }}
className="absolute top-8 left-6 rotate-[12deg] border-4 border-(--color-status-error) text-(--color-status-error) font-[var(--font-display)] font-extrabold text-[26px] px-3 py-1 rounded-(--radius-sm) bg-white/70"
>
ردّ
</motion.span>
</>
)}
<div className="absolute inset-x-0 bottom-0 p-5 pointer-events-none">
<div className="flex items-center gap-2">
<h2 className="font-[var(--font-display)] font-extrabold text-[24px] text-white">
{profile.first_name || "بدون نام"}
</h2>
<span className="font-[var(--font-display)] font-bold text-[20px] text-white/90">
{profile.age}
</span>
</div>
{profile.distance_km != null && (
<div className="flex items-center gap-1.5 mt-1.5">
<MapPin size={14} className="text-white/80" />
<span className="text-[13px] text-white/80">
{formatDistance(profile.distance_km)}
</span>
</div>
)}
</div>
</div>
</motion.div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X, Heart, Star, RotateCcw } from "lucide-react";
import { SwipeCard } from "./SwipeCard";
import { MatchModal } from "./MatchModal";
import { EmptyState } from "@/components/ui/EmptyState";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { formatApiError } from "@/lib/hooks";
import { DiscoverCandidate, MatchView, swipe } from "@/lib/api";
export function SwipeDeck({
profiles,
onReload,
}: {
profiles: DiscoverCandidate[];
onReload: () => void;
}) {
const [deck, setDeck] = useState<DiscoverCandidate[]>(profiles);
const [matched, setMatched] = useState<MatchView | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function decide(direction: "left" | "right", superlike = false) {
if (deck.length === 0 || busy) return;
const top = deck[0];
setBusy(true);
setError(null);
setDeck((prev) => prev.slice(1));
try {
const action = direction === "left" ? "pass" : superlike ? "superlike" : "like";
const result = await swipe(top.id, action);
if (result.matched && result.match) {
setMatched(result.match);
}
} catch (err) {
setError(formatApiError(err));
} finally {
setBusy(false);
}
}
const visible = deck.slice(0, 3);
return (
<div className="flex-1 flex flex-col px-5 pb-6">
{error && (
<div className="pb-3">
<ErrorBanner message={error} />
</div>
)}
<div className="relative flex-1">
{visible.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center">
<EmptyState
icon={<RotateCcw size={32} />}
title="فعلاً همه رو دیدی"
description="پروفایل جدیدی برای نشون دادن نداریم. بعداً دوباره سر بزن."
/>
<Button size="sm" onClick={onReload}>
بررسی مجدد
</Button>
</div>
</div>
) : (
<AnimatePresence>
{[...visible].reverse().map((profile, idxFromEnd) => {
const stackIndex = visible.length - 1 - idxFromEnd;
return (
<SwipeCard
key={profile.id}
profile={profile}
isTop={stackIndex === 0}
stackIndex={stackIndex}
onDecide={decide}
/>
);
})}
</AnimatePresence>
)}
</div>
{visible.length > 0 && (
<div className="flex items-center justify-center gap-5 pt-6">
<motion.button
whileTap={{ scale: 0.85 }}
onClick={() => decide("left")}
disabled={busy}
className="w-14 h-14 rounded-full bg-white flex items-center justify-center shadow-(--shadow-float) text-(--color-status-error) disabled:opacity-50"
aria-label="رد کردن"
>
<X size={26} strokeWidth={2.5} />
</motion.button>
<motion.button
whileTap={{ scale: 0.85 }}
onClick={() => decide("right", true)}
disabled={busy}
className="w-9 h-9 rounded-full bg-white flex items-center justify-center shadow-(--shadow-float) text-(--color-pop-purple) disabled:opacity-50"
aria-label="سوپرلایک"
>
<Star size={16} strokeWidth={2.5} />
</motion.button>
<motion.button
whileTap={{ scale: 0.85 }}
onClick={() => decide("right")}
disabled={busy}
className="w-16 h-16 rounded-full bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-md) text-white disabled:opacity-50"
aria-label="لایک"
>
<Heart size={28} fill="white" />
</motion.button>
</div>
)}
<MatchModal match={matched} onClose={() => setMatched(null)} />
</div>
);
}
@@ -0,0 +1,69 @@
"use client";
import { ReactNode } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { ONBOARDING_STEPS } from "@/lib/onboarding-steps";
interface OnboardingScreenProps {
order: number;
title: string;
emoji: string;
description?: string;
children: ReactNode;
onBack?: () => void;
}
export function OnboardingScreen({
order,
title,
emoji,
description,
children,
onBack,
}: OnboardingScreenProps) {
const router = useRouter();
const total = ONBOARDING_STEPS.length;
return (
<div className="flex flex-col flex-1 min-h-dvh">
<div className="flex items-center gap-3 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-2">
<button
onClick={onBack ?? (() => router.back())}
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) shrink-0"
aria-label="بازگشت"
>
<ChevronRight size={20} className="text-(--color-content-secondary)" />
</button>
<div className="flex-1 flex gap-1.5">
{ONBOARDING_STEPS.map((s) => (
<div
key={s.key}
className={`h-1.5 flex-1 rounded-(--radius-full) transition-colors duration-300 ${
s.order <= order ? "bg-(--color-brand-500)" : "bg-(--color-bg-tertiary)"
}`}
/>
))}
</div>
<span className="text-[11px] font-bold text-(--color-content-tertiary) shrink-0">
{order}/{total}
</span>
</div>
<div className="flex flex-col gap-2 px-6 pt-6 pb-2">
<h1 className="font-[var(--font-display)] font-extrabold text-[24px] text-(--color-content-primary) leading-snug">
{title} <span>{emoji}</span>
</h1>
{description && (
<p className="text-[14px] text-(--color-content-secondary) leading-6">
{description}
</p>
)}
</div>
<div className="flex-1 px-6 py-4 overflow-y-auto pb-[calc(env(safe-area-inset-bottom)+24px)]">
{children}
</div>
</div>
);
}
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/TextField";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { cn } from "@/lib/utils";
import { submitBasicInfo, FullProfile } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
export function BasicInfoStep({
initialProfile,
onNext,
}: {
initialProfile: FullProfile | null;
onNext: () => void;
}) {
const [gender, setGender] = useState<"male" | "female" | null>(
initialProfile?.user.gender ?? null
);
const [firstName, setFirstName] = useState(initialProfile?.user.first_name ?? "");
const [birthDate, setBirthDate] = useState(initialProfile?.user.birth_date ?? "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isValid = gender && firstName.trim().length >= 2 && birthDate;
async function handleSubmit() {
if (!isValid || !gender) return;
setLoading(true);
setError(null);
try {
await submitBasicInfo({ gender, first_name: firstName.trim(), birth_date: birthDate });
onNext();
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
جنسیت
</label>
<div className="grid grid-cols-2 gap-3">
{(["female", "male"] as const).map((g) => (
<button
key={g}
onClick={() => setGender(g)}
className={cn(
"h-14 rounded-(--radius-md) border-2 font-bold text-[15px] transition-all",
gender === g
? "bg-(--color-brand-500) border-(--color-brand-500) text-white shadow-(--shadow-cute-sm)"
: "bg-(--color-surface-default) border-(--color-border-default) text-(--color-content-secondary)"
)}
>
{g === "female" ? "زن 👩" : "مرد 👨"}
</button>
))}
</div>
</div>
<TextField
label="اسم کوچیکت چیه؟"
placeholder="مثلاً سارا"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<TextField
label="تاریخ تولد"
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
/>
<ErrorBanner message={error} />
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
بریم مرحله بعد
</Button>
</div>
);
}
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/TextField";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { submitBodyInfo, FullProfile } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
export function BodyInfoStep({
initialProfile,
onNext,
}: {
initialProfile: FullProfile | null;
onNext: () => void;
}) {
const [height, setHeight] = useState(
initialProfile?.user.height_cm?.toString() ?? ""
);
const [weight, setWeight] = useState(
initialProfile?.user.weight_kg?.toString() ?? ""
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const h = parseInt(height, 10);
const w = parseInt(weight, 10);
const isValid = h >= 100 && h <= 250 && w >= 30 && w <= 250;
async function handleSubmit() {
if (!isValid) return;
setLoading(true);
setError(null);
try {
await submitBodyInfo({ height_cm: h, weight_kg: w });
onNext();
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-6">
<TextField
label="قد (سانتی‌متر)"
type="number"
placeholder="مثلاً 172"
value={height}
onChange={(e) => setHeight(e.target.value)}
/>
<TextField
label="وزن (کیلوگرم)"
type="number"
placeholder="مثلاً 65"
value={weight}
onChange={(e) => setWeight(e.target.value)}
/>
<ErrorBanner message={error} />
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
بریم مرحله بعد
</Button>
</div>
);
}
@@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { OptionPicker } from "@/components/ui/OptionPicker";
import { getOptions, submitEducation, Option, FullProfile } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
function findSelection(profile: FullProfile | null, key: string): number[] {
return profile?.selections.find((s) => s.category_key === key)?.option_ids ?? [];
}
export function EducationStep({
initialProfile,
onNext,
}: {
initialProfile: FullProfile | null;
onNext: () => void;
}) {
const [levelOptions, setLevelOptions] = useState<Option[] | null>(null);
const [fieldOptions, setFieldOptions] = useState<Option[] | null>(null);
const [levelId, setLevelId] = useState<number[]>(
findSelection(initialProfile, "education_level")
);
const [fieldId, setFieldId] = useState<number[]>(
findSelection(initialProfile, "field_of_study")
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
Promise.all([getOptions("education_level"), getOptions("field_of_study")])
.then(([lvl, field]) => {
setLevelOptions(lvl);
setFieldOptions(field);
})
.catch((err) => setError(formatApiError(err)));
}, []);
const isValid = levelId.length === 1 && fieldId.length === 1;
async function handleSubmit() {
if (!isValid) return;
setLoading(true);
setError(null);
try {
await submitEducation({
education_level_option_id: levelId[0],
field_of_study_option_id: fieldId[0],
});
onNext();
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
if (!levelOptions || !fieldOptions) return <FullScreenLoader label="گزینه‌ها رو میاریم..." />;
if (levelOptions.length === 0 || fieldOptions.length === 0) {
return (
<ErrorBanner message="هنوز گزینه‌ای برای تحصیلات توی پنل ادمین ثبت نشده. اول باید ادمین این‌ها رو اضافه کنه." />
);
}
return (
<div className="flex flex-col gap-7">
<div className="flex flex-col gap-3">
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
سطح تحصیلات
</label>
<OptionPicker options={levelOptions} selectedIds={levelId} onChange={setLevelId} mode="single" />
</div>
<div className="flex flex-col gap-3">
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
رشته تحصیلی
</label>
<OptionPicker options={fieldOptions} selectedIds={fieldId} onChange={setFieldId} mode="single" />
</div>
<ErrorBanner message={error} />
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
بریم مرحله بعد
</Button>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { OptionPicker } from "@/components/ui/OptionPicker";
import { getOptions, CategoryKey, FullProfile } from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
interface GenericOptionStepProps {
initialProfile: FullProfile | null;
onNext: () => void;
category: CategoryKey;
mode: "single" | "multi";
min: number;
max?: number;
submitFn: (ids: number[]) => Promise<unknown>;
helperText?: string;
emptyMessage: string;
}
export function GenericOptionStep({
initialProfile,
onNext,
category,
mode,
min,
max,
submitFn,
helperText,
emptyMessage,
}: GenericOptionStepProps) {
const [options, setOptions] = useState<
Awaited<ReturnType<typeof getOptions>> | null
>(null);
const [selectedIds, setSelectedIds] = useState<number[]>(
initialProfile?.selections.find((s) => s.category_key === category)?.option_ids ?? []
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getOptions(category)
.then(setOptions)
.catch((err) => setError(formatApiError(err)));
}, [category]);
const isValid = selectedIds.length >= min && (max ? selectedIds.length <= max : true);
async function handleSubmit() {
if (!isValid) return;
setLoading(true);
setError(null);
try {
await submitFn(selectedIds);
onNext();
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
if (!options) return <FullScreenLoader label="گزینه‌ها رو میاریم..." />;
if (options.length === 0) {
return <ErrorBanner message={emptyMessage} />;
}
return (
<div className="flex flex-col gap-6">
{helperText && (
<p className="text-[13px] font-semibold text-(--color-content-secondary)">
{helperText} {max ? `(${selectedIds.length}/${max})` : ""}
</p>
)}
<OptionPicker
options={options}
selectedIds={selectedIds}
onChange={setSelectedIds}
mode={mode}
max={max}
/>
<ErrorBanner message={error} />
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
بریم مرحله بعد
</Button>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { cn } from "@/lib/utils";
import {
getAvailableQuestions,
submitProfileQuestions,
ProfileQuestion,
FullProfile,
} from "@/lib/api";
import { formatApiError } from "@/lib/hooks";
import { Check } from "lucide-react";
const REQUIRED = 3;
export function ProfileQuestionsStep({
initialProfile,
onNext,
}: {
initialProfile: FullProfile | null;
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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getAvailableQuestions()
.then(setQuestions)
.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];
});
}
const allAnswered =
selectedIds.length === REQUIRED &&
selectedIds.every((id) => (answers[id] ?? "").trim().length > 0);
async function handleSubmit() {
if (!allAnswered) return;
setLoading(true);
setError(null);
try {
await submitProfileQuestions(
selectedIds.map((id) => ({ question_id: id, answer: answers[id].trim() }))
);
onNext();
} catch (err) {
setError(formatApiError(err));
} finally {
setLoading(false);
}
}
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})
</p>
<div className="flex flex-col gap-2">
{questions.map((q) => {
const selected = selectedIds.includes(q.id);
const disabled = !selected && selectedIds.length >= REQUIRED;
return (
<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"
)}
>
<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>
</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>
)}
<ErrorBanner message={error} />
<Button size="lg" isBlock disabled={!allAnswered} isLoading={loading} onClick={handleSubmit}>
بریم مرحله بعد
</Button>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
interface AvatarProps {
name: string;
size?: "sm" | "md" | "lg";
color?: "brand" | "mint" | "yellow" | "sky" | "pink";
className?: string;
}
const sizeClasses = {
sm: "w-8 h-8 text-[12px]",
md: "w-11 h-11 text-[15px]",
lg: "w-16 h-16 text-[22px]",
};
const colorClasses = {
brand: "bg-(--color-brand-500) text-white",
mint: "bg-(--color-pop-mint) text-emerald-900",
yellow: "bg-(--color-pop-yellow) text-amber-900",
sky: "bg-(--color-pop-sky) text-sky-950",
pink: "bg-(--color-pop-pink) text-pink-950",
};
function initials(name: string) {
const parts = name.trim().split(" ").filter(Boolean);
if (parts.length === 0) return "؟";
if (parts.length === 1) return parts[0].slice(0, 2);
return parts[0][0] + parts[1][0];
}
export function Avatar({ name, size = "md", color = "brand", className }: AvatarProps) {
return (
<div
className={cn(
"rounded-full flex items-center justify-center font-bold shrink-0 shadow-(--shadow-cute-sm)",
sizeClasses[size],
colorClasses[color],
className
)}
title={name}
>
{initials(name)}
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { HTMLAttributes, useState } from "react";
import { cn } from "@/lib/utils";
import { X, Check } from "lucide-react";
type BadgeTone = "brand" | "success" | "warning" | "error" | "info" | "neutral";
const toneClasses: Record<BadgeTone, string> = {
brand: "bg-(--color-brand-100) text-(--color-brand-700)",
success: "bg-emerald-100 text-emerald-700",
warning: "bg-amber-100 text-amber-700",
error: "bg-red-100 text-red-700",
info: "bg-sky-100 text-sky-700",
neutral: "bg-(--color-bg-tertiary) text-(--color-content-secondary)",
};
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: BadgeTone;
}
export function Badge({ className, tone = "brand", children, ...props }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-(--radius-full) px-3 py-1 text-[12px] font-bold",
toneClasses[tone],
className
)}
{...props}
>
{children}
</span>
);
}
interface ChipProps {
label: string;
selected?: boolean;
onToggle?: (next: boolean) => void;
}
export function Chip({ label, selected: controlledSelected, onToggle }: ChipProps) {
const [internal, setInternal] = useState(false);
const selected = controlledSelected ?? internal;
return (
<button
type="button"
onClick={() => {
setInternal((s) => !s);
onToggle?.(!selected);
}}
className={cn(
"inline-flex items-center gap-1.5 h-8 rounded-(--radius-full) px-4 text-[13px] font-semibold transition-all duration-150 cursor-pointer border-2",
selected
? "bg-(--color-brand-500) border-(--color-brand-500) text-white shadow-(--shadow-cute-sm)"
: "bg-(--color-surface-default) border-(--color-border-default) text-(--color-content-secondary) hover:border-(--color-brand-300) hover:text-(--color-brand-600)"
)}
>
{selected && <Check size={14} />}
{label}
</button>
);
}
interface TagProps {
label: string;
onRemove?: () => void;
color?: "brand" | "mint" | "yellow" | "sky" | "pink";
}
const tagColorClasses: Record<NonNullable<TagProps["color"]>, string> = {
brand: "bg-(--color-brand-50) text-(--color-brand-700)",
mint: "bg-emerald-50 text-emerald-700",
yellow: "bg-amber-50 text-amber-700",
sky: "bg-sky-50 text-sky-700",
pink: "bg-pink-50 text-pink-700",
};
export function Tag({ label, onRemove, color = "brand" }: TagProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-(--radius-xs) px-2.5 py-1 text-[12px] font-semibold",
tagColorClasses[color]
)}
>
{label}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="rounded-full hover:bg-black/10 p-0.5 cursor-pointer"
aria-label={`حذف ${label}`}
>
<X size={11} />
</button>
)}
</span>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
export type ButtonVariant = "primary" | "secondary" | "tertiary" | "ghost";
export type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
isBlock?: boolean;
icon?: React.ReactNode;
}
const sizeClasses: Record<ButtonSize, string> = {
sm: "h-9 px-4 text-[13px] gap-1.5",
md: "h-11 px-5 text-[15px] gap-2",
lg: "h-13 px-6 text-[16px] gap-2.5",
};
const variantClasses: Record<ButtonVariant, string> = {
primary:
"bg-(--color-brand-500) text-(--color-brand-onPrimary) shadow-(--shadow-cute-md) hover:bg-(--color-brand-600) hover:-translate-y-0.5 hover:shadow-(--shadow-cute-lg) active:translate-y-0 active:bg-(--color-brand-700)",
secondary:
"bg-(--color-brand-100) text-(--color-brand-700) hover:bg-(--color-brand-200) active:bg-(--color-brand-300)",
tertiary:
"bg-(--color-surface-default) text-(--color-content-primary) border-2 border-(--color-border-default) hover:border-(--color-brand-300) hover:text-(--color-brand-600) active:bg-(--color-bg-secondary)",
ghost:
"bg-transparent text-(--color-content-secondary) hover:bg-(--color-bg-secondary) hover:text-(--color-brand-600)",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = "primary",
size = "md",
isLoading = false,
isBlock = false,
icon,
disabled,
children,
...props
},
ref
) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={cn(
"inline-flex items-center justify-center rounded-(--radius-md) font-[var(--font-sans)] font-semibold",
"transition-all duration-150 ease-out select-none cursor-pointer",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-(--color-brand-200)",
"disabled:opacity-45 disabled:pointer-events-none disabled:translate-y-0 disabled:shadow-none",
sizeClasses[size],
variantClasses[variant],
isBlock && "w-full",
className
)}
{...props}
>
{isLoading ? (
<Loader2 className="animate-spin" size={18} />
) : (
icon
)}
<span>{children}</span>
</button>
);
}
);
Button.displayName = "Button";
+27
View File
@@ -0,0 +1,27 @@
import { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
hoverable?: boolean;
padded?: boolean;
}
export function Card({
className,
hoverable = false,
padded = true,
...props
}: CardProps) {
return (
<div
className={cn(
"rounded-(--radius-lg) bg-(--color-surface-default) shadow-(--shadow-cute-sm) border border-(--color-border-subtle)",
padded && "p-5",
hoverable &&
"transition-all duration-200 hover:-translate-y-1 hover:shadow-(--shadow-cute-md) hover:border-(--color-brand-200)",
className
)}
{...props}
/>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
interface SwitchProps {
label?: string;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
}
export function Switch({ label, defaultChecked = false, onChange }: SwitchProps) {
const [checked, setChecked] = useState(defaultChecked);
return (
<label className="inline-flex items-center gap-3 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => {
setChecked((c) => !c);
onChange?.(!checked);
}}
className={cn(
"relative h-7 w-12 rounded-full transition-colors duration-200 shrink-0",
checked ? "bg-(--color-brand-500)" : "bg-(--color-bg-tertiary)"
)}
>
<span
className={cn(
"absolute top-1 left-1 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
{label && <span className="text-[14px] text-(--color-content-primary)">{label}</span>}
</label>
);
}
interface CheckboxProps {
label?: string;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
}
export function Checkbox({ label, defaultChecked = false, onChange }: CheckboxProps) {
const [checked, setChecked] = useState(defaultChecked);
return (
<label className="inline-flex items-center gap-2.5 cursor-pointer select-none">
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => {
setChecked((c) => !c);
onChange?.(!checked);
}}
className={cn(
"h-6 w-6 rounded-(--radius-xs) flex items-center justify-center transition-all duration-150 shrink-0 border-2",
checked
? "bg-(--color-brand-500) border-(--color-brand-500)"
: "bg-(--color-surface-default) border-(--color-border-default)"
)}
>
{checked && <Check size={15} className="text-white" strokeWidth={3} />}
</button>
{label && <span className="text-[14px] text-(--color-content-primary)">{label}</span>}
</label>
);
}
interface RadioGroupProps {
options: { value: string; label: string }[];
defaultValue?: string;
onChange?: (value: string) => void;
}
export function RadioGroup({ options, defaultValue, onChange }: RadioGroupProps) {
const [value, setValue] = useState(defaultValue ?? options[0]?.value);
return (
<div className="flex flex-col gap-3">
{options.map((opt) => {
const selected = value === opt.value;
return (
<label
key={opt.value}
className="inline-flex items-center gap-2.5 cursor-pointer select-none"
>
<button
type="button"
role="radio"
aria-checked={selected}
onClick={() => {
setValue(opt.value);
onChange?.(opt.value);
}}
className={cn(
"h-5 w-5 rounded-full flex items-center justify-center border-2 transition-colors shrink-0",
selected ? "border-(--color-brand-500)" : "border-(--color-border-default)"
)}
>
{selected && <span className="h-2.5 w-2.5 rounded-full bg-(--color-brand-500)" />}
</button>
<span className="text-[14px] text-(--color-content-primary)">{opt.label}</span>
</label>
);
})}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { ReactNode } from "react";
import { Button } from "./Button";
interface EmptyStateProps {
icon: ReactNode;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}
export function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center text-center gap-4 py-12 px-6">
<div className="w-20 h-20 rounded-(--radius-xl) bg-(--color-brand-50) flex items-center justify-center text-(--color-brand-500) [animation:wiggle_3s_ease-in-out_infinite]">
{icon}
</div>
<div className="flex flex-col gap-1.5 max-w-xs">
<h3 className="font-[var(--font-display)] font-bold text-[18px] text-(--color-content-primary)">
{title}
</h3>
<p className="text-[14px] text-(--color-content-secondary) leading-6">{description}</p>
</div>
{actionLabel && (
<Button size="sm" onClick={onAction}>
{actionLabel}
</Button>
)}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { AlertCircle } from "lucide-react";
export function ErrorBanner({ message }: { message?: string | null }) {
if (!message) return null;
return (
<div className="flex items-start gap-2 rounded-(--radius-sm) bg-red-50 border-2 border-red-100 text-(--color-status-error) px-4 py-3 text-[13px] font-medium">
<AlertCircle size={16} className="shrink-0 mt-0.5" />
<span>{message}</span>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Loader2 } from "lucide-react";
export function FullScreenLoader({ label = "یه لحظه..." }: { label?: string }) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 py-20">
<Loader2 size={28} className="animate-spin text-(--color-brand-500)" />
<p className="text-[13px] text-(--color-content-tertiary)">{label}</p>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { Option } from "@/lib/api";
interface OptionPickerProps {
options: Option[];
selectedIds: number[];
onChange: (ids: number[]) => void;
mode?: "single" | "multi";
max?: number;
}
export function OptionPicker({
options,
selectedIds,
onChange,
mode = "multi",
max,
}: OptionPickerProps) {
function toggle(id: number) {
const isSelected = selectedIds.includes(id);
if (mode === "single") {
onChange([id]);
return;
}
if (isSelected) {
onChange(selectedIds.filter((x) => x !== id));
} else {
if (max && selectedIds.length >= max) return;
onChange([...selectedIds, id]);
}
}
return (
<div className="flex flex-wrap gap-2.5">
{options.map((opt) => {
const selected = selectedIds.includes(opt.id);
const disabled =
!selected && mode === "multi" && !!max && selectedIds.length >= max;
return (
<button
key={opt.id}
type="button"
disabled={disabled}
onClick={() => toggle(opt.id)}
className={cn(
"inline-flex items-center gap-1.5 h-11 rounded-(--radius-full) px-5 text-[14px] font-semibold transition-all duration-150 border-2 active:scale-95",
selected
? "bg-(--color-surface-interactive) border-(--color-brand-500) text-(--color-brand-600)"
: "bg-(--color-surface-default) border-(--color-border-default) text-(--color-content-secondary)",
disabled && "opacity-40",
!disabled && !selected && "hover:border-(--color-brand-300) hover:text-(--color-brand-600)"
)}
>
{selected && <Check size={15} />}
{opt.title}
</button>
);
})}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useRef, useState, KeyboardEvent, ClipboardEvent } from "react";
import { cn } from "@/lib/utils";
interface OtpInputProps {
length?: number;
onComplete?: (code: string) => void;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
export function OtpInput({
length = 4,
value,
onChange,
onComplete,
disabled,
}: OtpInputProps) {
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
const [focusIndex, setFocusIndex] = useState(0);
const digits = value.split("").concat(Array(length).fill("")).slice(0, length);
function setDigit(index: number, digit: string) {
const next = [...digits];
next[index] = digit;
const joined = next.join("").slice(0, length);
onChange(joined);
if (digit && index < length - 1) {
inputsRef.current[index + 1]?.focus();
setFocusIndex(index + 1);
}
if (joined.length === length && !joined.includes("")) {
onComplete?.(joined);
}
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>, index: number) {
if (e.key === "Backspace" && !digits[index] && index > 0) {
inputsRef.current[index - 1]?.focus();
setFocusIndex(index - 1);
}
}
function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
if (pasted) {
e.preventDefault();
onChange(pasted);
if (pasted.length === length) onComplete?.(pasted);
inputsRef.current[Math.min(pasted.length, length - 1)]?.focus();
}
}
return (
<div className="flex items-center justify-center gap-3" dir="ltr">
{digits.map((d, i) => (
<input
key={i}
ref={(el) => {
inputsRef.current[i] = el;
}}
inputMode="numeric"
maxLength={1}
value={d}
disabled={disabled}
onFocus={() => setFocusIndex(i)}
onChange={(e) => setDigit(i, e.target.value.replace(/\D/g, "").slice(-1))}
onKeyDown={(e) => handleKeyDown(e, i)}
onPaste={handlePaste}
className={cn(
"w-14 h-16 text-center text-[24px] font-bold rounded-(--radius-md) border-2 transition-all",
"bg-(--color-surface-default) text-(--color-content-primary)",
"focus:outline-none focus:ring-4 focus:ring-(--color-brand-100)",
focusIndex === i || d
? "border-(--color-brand-500)"
: "border-(--color-border-default)"
)}
/>
))}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
export function ProgressBar({ value }: { value: number }) {
return (
<div className="h-3 w-full rounded-(--radius-full) bg-(--color-bg-tertiary) overflow-hidden">
<div
className="h-full rounded-(--radius-full) bg-linear-to-l from-(--color-brand-400) to-(--color-brand-600) transition-all duration-500"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
}
export function Skeleton({ className }: { className?: string }) {
return (
<div
className={cn(
"rounded-(--radius-sm) bg-(--color-bg-tertiary) relative overflow-hidden",
"before:absolute before:inset-0 before:-translate-x-full",
"before:bg-linear-to-l before:from-transparent before:via-white/60 before:to-transparent",
"before:animate-[shimmer_1.6s_infinite]",
className
)}
/>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { InputHTMLAttributes, forwardRef, useId } from "react";
import { cn } from "@/lib/utils";
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
helperText?: string;
errorText?: string;
leadingIcon?: React.ReactNode;
}
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
({ className, label, helperText, errorText, leadingIcon, id, ...props }, ref) => {
const autoId = useId();
const inputId = id ?? autoId;
const hasError = Boolean(errorText);
return (
<div className="flex flex-col gap-1.5 w-full">
{label && (
<label
htmlFor={inputId}
className="text-[13px] font-semibold text-(--color-content-secondary) px-1"
>
{label}
</label>
)}
<div className="relative">
{leadingIcon && (
<span className="absolute inset-y-0 right-4 flex items-center text-(--color-content-tertiary)">
{leadingIcon}
</span>
)}
<input
ref={ref}
id={inputId}
className={cn(
"h-12 w-full rounded-(--radius-sm) bg-(--color-surface-default)",
"border-2 border-(--color-border-default) px-4 text-[15px] text-(--color-content-primary)",
"placeholder:text-(--color-content-tertiary) transition-colors duration-150",
"focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100)",
hasError &&
"border-(--color-status-error) focus:border-(--color-status-error) focus:ring-red-100",
leadingIcon && "pr-11",
className
)}
{...props}
/>
</div>
{errorText ? (
<p className="text-[12px] text-(--color-status-error) px-1">{errorText}</p>
) : helperText ? (
<p className="text-[12px] text-(--color-content-tertiary) px-1">{helperText}</p>
) : null}
</div>
);
}
);
TextField.displayName = "TextField";
+457
View File
@@ -0,0 +1,457 @@
import { tokenStore } from "./token-store";
export const API_BASE =
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
// آدرس ریشه‌ی بک‌اند (بدون /api/v1) — برای ساختن URL کامل فایل‌های استاتیک (عکس‌ها)
export const API_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, "");
// url ذخیره‌شده توی دیتابیس یه مسیر نسبیه (مثلاً /uploads/photos/3/xxx.jpg)
export function photoUrl(path: string): string {
if (path.startsWith("http")) return path;
return `${API_ORIGIN}${path}`;
}
// ---------------- انواع پاسخ بک‌اند ----------------
interface ApiEnvelope<T> {
success: boolean;
message?: string;
data?: T;
errors?: unknown;
}
export class ApiError extends Error {
errors?: unknown;
status: number;
constructor(message: string, status: number, errors?: unknown) {
super(message);
this.status = status;
this.errors = errors;
}
}
export type ApprovalStatus = "pending" | "approved" | "rejected";
export interface UserProfile {
id: number;
phone: string;
first_name?: string;
gender?: "male" | "female";
birth_date?: string;
height_cm?: number;
weight_kg?: number;
current_step: number;
is_onboarded: boolean;
approval_status: ApprovalStatus;
rejection_reason?: string;
is_admin: boolean;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
}
export interface AuthResponseData {
tokens: TokenPair;
user: UserProfile;
is_new_user: boolean;
onboarding_done: boolean;
}
export interface StepView {
step: number;
key: string;
done: boolean;
}
export interface OnboardingStatus {
current_step: number;
current_step_key: string;
is_onboarded: boolean;
approval_status: ApprovalStatus;
rejection_reason?: string;
steps: StepView[];
}
export interface Option {
id: number;
category_id: number;
title: string;
sort_order: number;
is_active: boolean;
}
export interface ProfileQuestion {
id: number;
text: string;
sort_order: number;
is_active: boolean;
}
export interface SelectionSummary {
category_key: string;
option_ids: number[];
titles: string[];
}
export interface ProfileAnswer {
id: number;
user_id: number;
question_id: number;
answer: string;
question?: ProfileQuestion;
}
export interface Photo {
id: number;
user_id: number;
url: string;
sort_order: number;
}
export interface FullProfile {
user: UserProfile;
onboarding_status: OnboardingStatus;
selections: SelectionSummary[];
profile_answers: ProfileAnswer[];
photos: Photo[];
}
// ---------------- دوستیابی ----------------
export interface DiscoverCandidate {
id: number;
first_name: string;
age: number;
photos: string[];
distance_km?: number;
interests?: string[];
}
export interface CandidateAnswer {
question: string;
answer: string;
}
export interface CandidateDetail {
id: number;
first_name: string;
age: number;
photos: string[];
distance_km?: number;
height_cm?: number;
weight_kg?: number;
education?: string;
field_of_study?: string;
occupations?: string[];
interests?: string[];
important_in_relationship?: string[];
looking_for?: string;
profile_answers?: CandidateAnswer[];
}
export type SwipeActionType = "like" | "pass" | "superlike";
export interface MatchView {
id: number;
user_id: number;
first_name: string;
photos: string[];
created_at: string;
last_message?: string;
last_at?: string;
}
export interface SwipeResult {
matched: boolean;
match?: MatchView;
}
export interface MessageView {
id: number;
match_id: number;
sender_id: number;
text: string;
created_at: string;
is_mine: boolean;
}
export type CategoryKey =
| "education_level"
| "field_of_study"
| "occupation"
| "important_in_relationship"
| "interest"
| "looking_for";
// ---------------- هسته‌ی fetch ----------------
let refreshPromise: Promise<boolean> | null = null;
async function rawRequest<T>(
path: string,
options: RequestInit = {},
auth = false
): Promise<T> {
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
if (auth) {
const token = tokenStore.getAccess();
if (token) headers.set("Authorization", `Bearer ${token}`);
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
let body: ApiEnvelope<T> | null = null;
try {
body = await res.json();
} catch {
// بدنه خالی (مثلاً بعضی پاسخ‌های موفق بدون data)
}
if (!res.ok) {
throw new ApiError(
body?.message ?? "خطای غیرمنتظره از سرور",
res.status,
body?.errors
);
}
return (body?.data as T) ?? (undefined as T);
}
/**
* درخواست احراز هویت‌دار؛ اگه توکن access منقضی شده باشه (۴۰۱)،
* یک‌بار تلاش می‌کنه با refresh token توکن جدید بگیره و دوباره درخواست رو بفرسته.
*/
async function authRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
try {
return await rawRequest<T>(path, options, true);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
const refreshed = await ensureRefreshed();
if (refreshed) {
return await rawRequest<T>(path, options, true);
}
}
throw err;
}
}
/** مثل authRequest ولی برای آپلود فایل (بدون ست‌کردن Content-Type، چون FormData خودش boundary می‌سازه) */
async function authUpload<T>(path: string, formData: FormData): Promise<T> {
async function send(): Promise<T> {
const headers = new Headers();
const token = tokenStore.getAccess();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers });
let body: ApiEnvelope<T> | null = null;
try {
body = await res.json();
} catch {
// بدنه خالی
}
if (!res.ok) {
throw new ApiError(body?.message ?? "خطای غیرمنتظره از سرور", res.status, body?.errors);
}
return (body?.data as T) ?? (undefined as T);
}
try {
return await send();
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
const refreshed = await ensureRefreshed();
if (refreshed) return await send();
}
throw err;
}
}
async function ensureRefreshed(): Promise<boolean> {
const refresh = tokenStore.getRefresh();
if (!refresh) return false;
if (!refreshPromise) {
refreshPromise = rawRequest<TokenPair>("/auth/token/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: refresh }),
})
.then((tokens) => {
tokenStore.set(tokens.access_token, tokens.refresh_token);
return true;
})
.catch(() => {
tokenStore.clear();
return false;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
// ---------------- احراز هویت ----------------
export function requestOtp(phone: string) {
return rawRequest<null>("/auth/request-otp", {
method: "POST",
body: JSON.stringify({ phone }),
});
}
export function verifyOtp(phone: string, otp: string) {
return rawRequest<AuthResponseData>("/auth/verify-otp", {
method: "POST",
body: JSON.stringify({ phone, otp }),
});
}
// ---------------- پروفایل ----------------
export function getProfileMe() {
return authRequest<FullProfile>("/profile/me");
}
// ---------------- آنبوردینگ ----------------
export function getOnboardingStatus() {
return authRequest<OnboardingStatus>("/onboarding/status");
}
export function getOptions(category: CategoryKey) {
return authRequest<Option[]>(`/onboarding/options/${category}`);
}
export function getAvailableQuestions() {
return authRequest<ProfileQuestion[]>("/onboarding/profile-questions");
}
export function submitBasicInfo(payload: {
gender: "male" | "female";
first_name: string;
birth_date: string;
}) {
return authRequest<OnboardingStatus>("/onboarding/basic-info", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function submitBodyInfo(payload: { height_cm: number; weight_kg: number }) {
return authRequest<OnboardingStatus>("/onboarding/body-info", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function submitProfileQuestions(
answers: { question_id: number; answer: string }[]
) {
return authRequest<OnboardingStatus>("/onboarding/profile-questions", {
method: "POST",
body: JSON.stringify({ answers }),
});
}
export function submitEducation(payload: {
education_level_option_id: number;
field_of_study_option_id: number;
}) {
return authRequest<OnboardingStatus>("/onboarding/education", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function submitOccupations(optionIds: number[]) {
return authRequest<OnboardingStatus>("/onboarding/occupations", {
method: "POST",
body: JSON.stringify({ option_ids: optionIds }),
});
}
export function submitRelationshipImportance(optionIds: number[]) {
return authRequest<OnboardingStatus>("/onboarding/relationship-importance", {
method: "POST",
body: JSON.stringify({ option_ids: optionIds }),
});
}
export function submitInterests(optionIds: number[]) {
return authRequest<OnboardingStatus>("/onboarding/interests", {
method: "POST",
body: JSON.stringify({ option_ids: optionIds }),
});
}
export function submitLookingFor(optionIds: number[]) {
return authRequest<OnboardingStatus>("/onboarding/looking-for", {
method: "POST",
body: JSON.stringify({ option_ids: optionIds }),
});
}
// ---------------- عکس‌ها و موقعیت مکانی ----------------
export function uploadPhoto(file: File) {
const formData = new FormData();
formData.append("photo", file);
return authUpload<Photo>("/profile/photos", formData);
}
export function deletePhoto(id: number) {
return authRequest<null>(`/profile/photos/${id}`, { method: "DELETE" });
}
export function updateLocation(latitude: number, longitude: number) {
return authRequest<null>("/profile/location", {
method: "PATCH",
body: JSON.stringify({ latitude, longitude }),
});
}
// ---------------- دوستیابی ----------------
export function getDiscoverFeed(params?: {
maxDistanceKm?: number;
minAge?: number;
maxAge?: number;
}) {
const query = new URLSearchParams();
if (params?.maxDistanceKm) query.set("max_distance_km", String(params.maxDistanceKm));
if (params?.minAge) query.set("min_age", String(params.minAge));
if (params?.maxAge) query.set("max_age", String(params.maxAge));
const qs = query.toString();
return authRequest<DiscoverCandidate[]>(`/discover${qs ? `?${qs}` : ""}`);
}
export function getCandidateDetail(id: number) {
return authRequest<CandidateDetail>(`/discover/${id}`);
}
export function swipe(targetId: number, action: SwipeActionType) {
return authRequest<SwipeResult>(`/discover/${targetId}/swipe`, {
method: "POST",
body: JSON.stringify({ action }),
});
}
export function listMatches() {
return authRequest<MatchView[]>("/matches");
}
export function listMessages(matchId: number) {
return authRequest<MessageView[]>(`/matches/${matchId}/messages`);
}
export function sendMessage(matchId: number, text: string) {
return authRequest<MessageView>(`/matches/${matchId}/messages`, {
method: "POST",
body: JSON.stringify({ text }),
});
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
ReactNode,
} from "react";
import { tokenStore } from "./token-store";
import {
UserProfile,
OnboardingStatus,
verifyOtp as apiVerifyOtp,
getOnboardingStatus,
getProfileMe,
} from "./api";
type AuthStatus = "loading" | "guest" | "authenticated";
interface AuthContextValue {
status: AuthStatus;
user: UserProfile | null;
onboardingStatus: OnboardingStatus | null;
login: (phone: string, otp: string) => Promise<UserProfile>;
logout: () => void;
refreshOnboardingStatus: () => Promise<OnboardingStatus | null>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>("loading");
const [user, setUser] = useState<UserProfile | null>(null);
const [onboardingStatus, setOnboardingStatus] = useState<OnboardingStatus | null>(
null
);
const bootstrap = useCallback(async () => {
const access = tokenStore.getAccess();
if (!access) {
setStatus("guest");
return;
}
try {
const profile = await getProfileMe();
setUser(profile.user);
setOnboardingStatus(profile.onboarding_status);
setStatus("authenticated");
} catch {
tokenStore.clear();
setStatus("guest");
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
bootstrap();
}, [bootstrap]);
const login = useCallback(async (phone: string, otp: string) => {
const data = await apiVerifyOtp(phone, otp);
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
setUser(data.user);
setStatus("authenticated");
// وضعیت کامل مراحل رو جدا می‌گیریم چون verify-otp فقط current_step/is_onboarded می‌ده
const fullStatus = await getOnboardingStatus();
setOnboardingStatus(fullStatus);
return data.user;
}, []);
const logout = useCallback(() => {
tokenStore.clear();
setUser(null);
setOnboardingStatus(null);
setStatus("guest");
}, []);
const refreshOnboardingStatus = useCallback(async () => {
try {
const s = await getOnboardingStatus();
setOnboardingStatus(s);
return s;
} catch {
return null;
}
}, []);
const refreshUser = useCallback(async () => {
const profile = await getProfileMe();
setUser(profile.user);
setOnboardingStatus(profile.onboarding_status);
}, []);
return (
<AuthContext.Provider
value={{
status,
user,
onboardingStatus,
login,
logout,
refreshOnboardingStatus,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth باید داخل AuthProvider استفاده بشه");
return ctx;
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "./auth-context";
import { ApiError } from "./api";
export function useRequireAuth() {
const router = useRouter();
const { status, user } = useAuth();
useEffect(() => {
if (status === "guest") {
router.replace("/login");
}
}, [status, router]);
return { status, user };
}
export function formatApiError(err: unknown, fallback = "یه مشکلی پیش اومد؛ دوباره امتحان کن"): string {
if (err instanceof ApiError) {
if (typeof err.errors === "string" && err.errors && err.errors !== err.message) {
return `${err.message} — ${err.errors}`;
}
return err.message || fallback;
}
if (err instanceof Error) return err.message;
return fallback;
}
+40
View File
@@ -0,0 +1,40 @@
export interface StepMeta {
key: string;
slug: string;
order: number;
title: string;
emoji: string;
}
// همون ترتیب و کلیدهایی که بک‌اند در models.OnboardingStepOrder برمی‌گردونه
export const ONBOARDING_STEPS: StepMeta[] = [
{ key: "basic_info", slug: "basic-info", order: 1, title: "خب اول بگو کی هستی", emoji: "👋" },
{ key: "body_info", slug: "body-info", order: 2, title: "قد و وزنت چقدره؟", emoji: "📏" },
{ key: "profile_questions", slug: "profile-questions", order: 3, title: "یکم از خودت بگو", emoji: "💬" },
{ key: "education", slug: "education", order: 4, title: "تحصیلاتت چیه؟", emoji: "🎓" },
{ key: "occupations", slug: "occupations", order: 5, title: "چیکار می‌کنی؟", emoji: "💼" },
{
key: "relationship_importance",
slug: "relationship-importance",
order: 6,
title: "توی رابطه چی برات مهمه؟",
emoji: "❤️",
},
{ key: "interests", slug: "interests", order: 7, title: "به چی علاقه داری؟", emoji: "✨" },
{ key: "looking_for", slug: "looking-for", order: 8, title: "دنبال چی می‌گردی؟", emoji: "🔍" },
];
export function stepBySlug(slug: string) {
return ONBOARDING_STEPS.find((s) => s.slug === slug);
}
export function stepByOrder(order: number) {
return ONBOARDING_STEPS.find((s) => s.order === order);
}
export function nextStepSlug(currentSlug: string): string | null {
const current = stepBySlug(currentSlug);
if (!current) return null;
const next = ONBOARDING_STEPS.find((s) => s.order === current.order + 1);
return next ? next.slug : null;
}
+27
View File
@@ -0,0 +1,27 @@
const ACCESS_KEY = "dating_app_access_token";
const REFRESH_KEY = "dating_app_refresh_token";
function isBrowser() {
return typeof window !== "undefined";
}
export const tokenStore = {
getAccess(): string | null {
if (!isBrowser()) return null;
return localStorage.getItem(ACCESS_KEY);
},
getRefresh(): string | null {
if (!isBrowser()) return null;
return localStorage.getItem(REFRESH_KEY);
},
set(access: string, refresh: string) {
if (!isBrowser()) return;
localStorage.setItem(ACCESS_KEY, access);
localStorage.setItem(REFRESH_KEY, refresh);
},
clear() {
if (!isBrowser()) return;
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
},
};
+24
View File
@@ -0,0 +1,24 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
// فاصله رو به‌صورت تقریبی گرد می‌کنه (دقیقاً مثل اپ‌های دوستیابی: ۲۰ کیلومتر، ۱۰۰ کیلومتر و...)
export function formatDistance(km: number): string {
if (km < 1) return "کمتر از 1 کیلومتر";
if (km < 20) return `${Math.round(km)} کیلومتر`;
const rounded = Math.round(km / 5) * 5;
return `${rounded} کیلومتر`;
}
export function calcAge(birthDate?: string): number | null {
if (!birthDate) return null;
const b = new Date(birthDate);
if (Number.isNaN(b.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - b.getFullYear();
const m = now.getMonth() - b.getMonth();
if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--;
return age;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}