chore: merge web repo history into monorepo at web/
git-subtree-dir: web git-subtree-mainline:75a0ba593dgit-subtree-split:8f624e7416
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
!.env.local.example
|
||||||
|
npm-debug.log*
|
||||||
|
uploads
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# آدرس بکاند Go (بدون اسلش آخر)
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 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
|
||||||
|
*.zip
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ---- وابستگیها ----
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---- build ----
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# NEXT_PUBLIC_* موقع build توی باندل کلاینت جاسازی میشه، پس باید همینجا
|
||||||
|
# بهعنوان build arg پاس داده بشه (نه فقط env زمان اجرا)
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- اجرا (ایمیج نهایی سبک، فقط خروجی standalone) ----
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
+103
@@ -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 واقعی
|
||||||
|
جایگزین کنی.
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// برای build خروجی مستقل (بدون نیاز به node_modules کامل) — لازم برای ایمیج Docker سبک
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+6870
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"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.bio && (
|
||||||
|
<p className="text-[14.5px] text-(--color-content-primary) leading-7 -mt-1">
|
||||||
|
{profile.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Bell, HourglassIcon, XCircle, SlidersHorizontal, Heart, Pencil } 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 { StreakBadge } from "@/components/StreakBadge";
|
||||||
|
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 router = useRouter();
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={<Pencil size={14} />}
|
||||||
|
onClick={() => router.push("/onboarding/basic-info")}
|
||||||
|
>
|
||||||
|
هنوز وقت هست؛ اطلاعاتت رو ویرایش کن
|
||||||
|
</Button>
|
||||||
|
</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("/onboarding/basic-info")}>
|
||||||
|
ویرایش پروفایل
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscoverScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { streak } = useAuth();
|
||||||
|
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.unlimitedDistance ? undefined : 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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-brand-600)">
|
||||||
|
دلدل 💌
|
||||||
|
</span>
|
||||||
|
<StreakBadge count={streak} />
|
||||||
|
</div>
|
||||||
|
<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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { ChevronRight, Send, User, Lock } 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";
|
||||||
|
import { ensureKeyPair, encryptMessage, decryptMessage } from "@/lib/e2ee";
|
||||||
|
|
||||||
|
const POLL_MS = 4000;
|
||||||
|
const UNREADABLE = "🔒 این پیام رو نمیتونی روی این دستگاه بخونی";
|
||||||
|
|
||||||
|
// پیام رمزنگاریشده رو برای نمایش رمزگشایی میکنه؛ پیام قدیمیِ قبل از E2EE
|
||||||
|
// همینجوری برگردونده میشه.
|
||||||
|
function messageText(
|
||||||
|
m: MessageView,
|
||||||
|
partnerPublicKey: string | undefined,
|
||||||
|
mySecretKey: Uint8Array | null
|
||||||
|
): string {
|
||||||
|
if (m.cipher_text && m.nonce) {
|
||||||
|
if (!mySecretKey || !partnerPublicKey) return UNREADABLE;
|
||||||
|
return decryptMessage(m.cipher_text, m.nonce, partnerPublicKey, mySecretKey) ?? UNREADABLE;
|
||||||
|
}
|
||||||
|
return m.text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [mySecretKey, setMySecretKey] = useState<Uint8Array | null>(null);
|
||||||
|
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();
|
||||||
|
ensureKeyPair()
|
||||||
|
.then((kp) => setMySecretKey(kp.secretKey))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
pollRef.current = setInterval(refreshMessages, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, [matchId, refreshMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||||
|
}, [messages?.length]);
|
||||||
|
|
||||||
|
const canEncrypt = !!mySecretKey && !!match?.partner_public_key;
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text || sending || !mySecretKey || !match?.partner_public_key) return;
|
||||||
|
setSending(true);
|
||||||
|
setDraft("");
|
||||||
|
try {
|
||||||
|
const { cipherText, nonce } = encryptMessage(text, match.partner_public_key, mySecretKey);
|
||||||
|
const msg = await sendMessage(matchId, cipherText, nonce);
|
||||||
|
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">
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-[11px] text-(--color-content-tertiary) text-center py-2 px-4 mx-auto">
|
||||||
|
<Lock size={11} />
|
||||||
|
پیامها با رمزنگاری سرتاسری محافظت میشن؛ هیچکس جز شما دو نفر، حتی ادمینهای دلدل، نمیتونه بخوندشون.
|
||||||
|
</p>
|
||||||
|
{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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{messageText(m, match?.partner_public_key, mySecretKey)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
|
||||||
|
{!canEncrypt && (
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary) text-center px-4 pb-1 flex items-center justify-center gap-1.5">
|
||||||
|
<Lock size={12} />
|
||||||
|
طرف مقابل هنوز رمزنگاری سرتاسری رو فعال نکرده؛ تا فعال نشه نمیتونی پیام بفرستی.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<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="پیامت رو بنویس..."
|
||||||
|
disabled={!canEncrypt}
|
||||||
|
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) disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!draft.trim() || sending || !canEncrypt}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"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";
|
||||||
|
import { ensureKeyPair, decryptMessage } from "@/lib/e2ee";
|
||||||
|
|
||||||
|
// پیشنمایش آخرین پیام هر مچ رو برمیگردونه. سرور نمیتونه رمزگشایی کنه، پس
|
||||||
|
// اگه پیام رمزنگاریشده باشه، همینجا با کلید محلی رمزگشایی میشه؛ اگه کلید
|
||||||
|
// جور نبود (مثلاً این پیام روی یه دستگاه دیگه رمزنگاری شده) یه نشونهی قفل نشون میده.
|
||||||
|
function previewFor(m: MatchView, mySecretKey: Uint8Array | null): string | undefined {
|
||||||
|
if (m.last_message_cipher_text && m.last_message_nonce) {
|
||||||
|
if (!mySecretKey || !m.partner_public_key) return "🔒 پیام رمزنگاریشده";
|
||||||
|
const plain = decryptMessage(m.last_message_cipher_text, m.last_message_nonce, m.partner_public_key, mySecretKey);
|
||||||
|
return plain ?? "🔒 این پیام رو نمیتونی روی این دستگاه ببینی";
|
||||||
|
}
|
||||||
|
return m.last_message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessagesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [matches, setMatches] = useState<MatchView[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mySecretKey, setMySecretKey] = useState<Uint8Array | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listMatches()
|
||||||
|
.then(setMatches)
|
||||||
|
.catch((err) => setError(formatApiError(err)));
|
||||||
|
ensureKeyPair()
|
||||||
|
.then((kp) => setMySecretKey(kp.secretKey))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error && !matches) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!matches) return <FullScreenLoader />;
|
||||||
|
|
||||||
|
const hasLastMessage = (m: MatchView) => !!m.last_message || !!m.last_message_cipher_text;
|
||||||
|
const newMatches = matches.filter((m) => !hasLastMessage(m));
|
||||||
|
const conversations = matches.filter(hasLastMessage);
|
||||||
|
|
||||||
|
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)">
|
||||||
|
{previewFor(c, mySecretKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
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 { Button } from "@/components/ui/Button";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { getProfileMe, uploadPhoto, deletePhoto, updateBio, photoUrl, FullProfile, Photo } from "@/lib/api";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { calcAge, validatePhotoSize } from "@/lib/utils";
|
||||||
|
|
||||||
|
function selectionTitles(profile: FullProfile, key: string): string[] {
|
||||||
|
return profile.selections.find((s) => s.category_key === key)?.titles ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletenessItem {
|
||||||
|
label: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCompleteness(profile: FullProfile): { pct: number; items: CompletenessItem[] } {
|
||||||
|
const items: CompletenessItem[] = [
|
||||||
|
{ label: "بیوگرافی", done: !!profile.user.bio?.trim() },
|
||||||
|
{ label: "حداقل ۳ عکس", done: profile.photos.length >= 3 },
|
||||||
|
{ label: "قد و وزن", done: !!profile.user.height_cm && !!profile.user.weight_kg },
|
||||||
|
{ label: "تحصیلات", done: selectionTitles(profile, "education_level").length > 0 },
|
||||||
|
{ label: "فعالیتها", done: selectionTitles(profile, "occupation").length > 0 },
|
||||||
|
{ label: "علاقهمندیها", done: selectionTitles(profile, "interest").length >= 5 },
|
||||||
|
{ label: "دنبال چی میگردی", done: selectionTitles(profile, "looking_for").length > 0 },
|
||||||
|
{ label: "معرفی خودت", done: profile.profile_answers.length >= 3 },
|
||||||
|
];
|
||||||
|
const doneCount = items.filter((i) => i.done).length;
|
||||||
|
return { pct: Math.round((doneCount / items.length) * 100), items };
|
||||||
|
}
|
||||||
|
|
||||||
|
// نوار تکمیل پروفایل؛ حلقهی «سرمایهگذاری» مدل Hook: هرچی کاربر بیشتر روی
|
||||||
|
// پروفایلش وقت بذاره، حس ازدسترفتنِ پیشرفت بیشتر میشه و برمیگرده تکمیلش کنه.
|
||||||
|
function ProfileCompleteness({ profile }: { profile: FullProfile }) {
|
||||||
|
const { pct, items } = computeCompleteness(profile);
|
||||||
|
const missing = items.filter((i) => !i.done);
|
||||||
|
|
||||||
|
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-[13px] font-extrabold text-(--color-brand-600)">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 rounded-(--radius-full) bg-(--color-bg-tertiary) overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-(--radius-full) bg-(--color-brand-500)"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.7, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{pct < 100 ? (
|
||||||
|
<p className="text-[12.5px] text-(--color-content-tertiary)">
|
||||||
|
{missing.length} مورد مونده: {missing.map((m) => m.label).join("، ")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[12.5px] text-emerald-600 font-semibold">پروفایلت کامله؛ آفرین! 🎉</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BadgeDef {
|
||||||
|
emoji: string;
|
||||||
|
label: string;
|
||||||
|
achieved: boolean;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBadges(profile: FullProfile, streak: number): BadgeDef[] {
|
||||||
|
const { pct } = computeCompleteness(profile);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
emoji: "🔥",
|
||||||
|
label: "استریک",
|
||||||
|
achieved: streak >= 3,
|
||||||
|
hint: streak >= 3 ? `${streak} روز متوالی اومدی` : "۳ روز پشتسرهم بیا",
|
||||||
|
},
|
||||||
|
{ emoji: "📸", label: "عکاس", achieved: profile.photos.length >= 6, hint: "۶ عکس آپلود کن" },
|
||||||
|
{ emoji: "💯", label: "کامل", achieved: pct === 100, hint: "پروفایلت رو ۱۰۰٪ کن" },
|
||||||
|
{ emoji: "📝", label: "خودشناس", achieved: !!profile.user.bio?.trim(), hint: "بیو بنویس" },
|
||||||
|
{ emoji: "🎯", label: "رکوراست", achieved: profile.profile_answers.length >= 3, hint: "به ۳ سوال جواب بده" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// شبکهی نشانها؛ نشانهای نگرفته خاکستری و کمرنگ میمونن (curiosity gap) تا
|
||||||
|
// انگیزهی جمع کردن کاملشون رو بده.
|
||||||
|
function BadgesGrid({ badges }: { badges: BadgeDef[] }) {
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col gap-3">
|
||||||
|
<span className="text-[13px] font-bold text-(--color-content-secondary)">نشانها</span>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{badges.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.label}
|
||||||
|
className="flex flex-col items-center gap-1"
|
||||||
|
title={b.achieved ? b.label : b.hint}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={b.achieved ? { scale: [0.85, 1.08, 1] } : { scale: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className={`w-12 h-12 rounded-(--radius-md) flex items-center justify-center text-[22px] ${
|
||||||
|
b.achieved
|
||||||
|
? "bg-(--color-brand-50)"
|
||||||
|
: "bg-(--color-bg-tertiary) grayscale opacity-40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{b.emoji}
|
||||||
|
</motion.div>
|
||||||
|
<span className="text-[10px] text-(--color-content-tertiary) text-center leading-tight">
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BioSection({ bio, onChange }: { bio: string; onChange: (bio: string) => void }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [value, setValue] = useState(bio);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
await updateBio(trimmed);
|
||||||
|
onChange(trimmed);
|
||||||
|
setEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{!editing && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setValue(bio);
|
||||||
|
setEditing(true);
|
||||||
|
}}
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value.slice(0, 300))}
|
||||||
|
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 className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-(--color-content-tertiary)">{value.length}/300</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" isLoading={saving} onClick={handleSave}>
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : bio ? (
|
||||||
|
<p className="text-[14px] text-(--color-content-primary) leading-6">{bio}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary)">
|
||||||
|
هنوز بیو ننوشتی؛ یه چیزی بنویس تا بقیه بهتر بشناسنت.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const sizeError = validatePhotoSize(file);
|
||||||
|
if (sizeError) {
|
||||||
|
setError(sizeError);
|
||||||
|
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 { streak } = useAuth();
|
||||||
|
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>
|
||||||
|
|
||||||
|
<ProfileCompleteness profile={profile} />
|
||||||
|
<BadgesGrid badges={computeBadges(profile, streak)} />
|
||||||
|
|
||||||
|
{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))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BioSection
|
||||||
|
bio={profile.user.bio ?? ""}
|
||||||
|
onChange={(bio) => setProfile((p) => (p ? { ...p, user: { ...p.user, bio } } : 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ChevronRight, ChevronDown, HelpCircle } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
|
||||||
|
const FAQ: { q: string; a: string }[] = [
|
||||||
|
{
|
||||||
|
q: "چرا پروفایلم هنوز تایید نشده؟",
|
||||||
|
a: "بعد از تکمیل تمام مراحل ثبتنام، یکی از ادمینهای دلدل پروفایلت رو بررسی میکنه. معمولاً این کار زیاد طول نمیکشه؛ تا اون موقع میتونی هر وقت خواستی برگردی و اطلاعاتت رو ویرایش کنی.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "چرا پروفایلم رد شد؟",
|
||||||
|
a: "دلیل رد دقیقاً توی صفحهی اصلی برات نمایش داده میشه (مثلاً عکس واضح نیست یا اطلاعات ناقصه). میتونی اطلاعاتت رو اصلاح کنی و دوباره برای بررسی بفرستی.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "فاصلهای که کنار هر پروفایل میبینم دقیقه؟",
|
||||||
|
a: "نه، عمداً تقریبیه (گرد شده به نزدیکترین کیلومتر) تا موقعیت دقیق هیچکس لو نره.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "چطور میتونم محدودهی سنی یا فاصله رو تغییر بدم؟",
|
||||||
|
a: "توی صفحهی کشف، روی آیکون فیلتر (کنار آیکون قلب) بزن. اونجا میتونی محدودهی سنی، حداکثر فاصله، یا حتی جستجوی بدون محدودیت فاصله رو فعال کنی.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "چرا دیگه پروفایل جدیدی برام نمیاد؟",
|
||||||
|
a: "یعنی با فیلترهای فعلیت، پیشنهاد جدیدی نمونده. فیلترها رو بازتر کن یا بعداً دوباره سر بزن؛ تا اون موقع میتونی یه بازی کوچیک هم بزنی!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "چطور با یکی چت کنم؟",
|
||||||
|
a: "وقتی دو نفر متقابلاً همدیگه رو لایک کنن، یه «مچ» ساخته میشه و بخش پیامها براتون باز میشه.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "میتونم شماره موبایلم رو عوض کنم؟",
|
||||||
|
a: "فعلاً از داخل اپ امکانش نیست. با پشتیبانی در ارتباط باش.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function FaqItem({ q, a }: { q: string; a: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="border-b border-(--color-border-subtle) last:border-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="w-full flex items-center justify-between gap-3 px-4 py-3.5 text-right"
|
||||||
|
>
|
||||||
|
<span className="text-[14px] font-bold text-(--color-content-primary)">{q}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-(--color-content-tertiary) shrink-0 transition-transform duration-200 ${
|
||||||
|
open ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<p className="px-4 pb-4 text-[13.5px] text-(--color-content-secondary) leading-6">{a}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpSettingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
|
||||||
|
aria-label="بازگشت"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
||||||
|
</button>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
|
||||||
|
راهنما و پشتیبانی 💬
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{FAQ.map((item) => (
|
||||||
|
<FaqItem key={item.q} q={item.q} a={item.a} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex items-start gap-2.5">
|
||||||
|
<HelpCircle size={16} className="text-(--color-brand-500) shrink-0 mt-0.5" />
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary) leading-6">
|
||||||
|
سوالت اینجا نبود؟ فعلاً کانال پشتیبانی مستقیم داخل اپ نداریم؛ بهزودی اضافه میشه.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ChevronRight, Heart, MessageCircle, Star } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Switch } from "@/components/ui/Switch";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { getProfileMe, updateNotificationSettings, NotificationSettings } from "@/lib/api";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
|
||||||
|
const ITEMS: { key: keyof NotificationSettings; icon: React.ReactNode; label: string; hint: string }[] = [
|
||||||
|
{
|
||||||
|
key: "notify_matches",
|
||||||
|
icon: <Heart size={18} className="text-(--color-brand-500)" />,
|
||||||
|
label: "مچ جدید",
|
||||||
|
hint: "وقتی با کسی متقابلاً همدیگه رو لایک کردین",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notify_messages",
|
||||||
|
icon: <MessageCircle size={18} className="text-(--color-brand-500)" />,
|
||||||
|
label: "پیام جدید",
|
||||||
|
hint: "وقتی یکی از مچهات برات پیام میفرسته",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notify_likes",
|
||||||
|
icon: <Star size={18} className="text-(--color-brand-500)" />,
|
||||||
|
label: "لایک جدید",
|
||||||
|
hint: "وقتی یکی پروفایلت رو لایک میکنه",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NotificationsSettingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getProfileMe()
|
||||||
|
.then((p) =>
|
||||||
|
setSettings({
|
||||||
|
notify_matches: p.user.notify_matches,
|
||||||
|
notify_messages: p.user.notify_messages,
|
||||||
|
notify_likes: p.user.notify_likes,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((err) => setError(formatApiError(err)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleToggle(key: keyof NotificationSettings, value: boolean) {
|
||||||
|
if (!settings) return;
|
||||||
|
const next = { ...settings, [key]: value };
|
||||||
|
setSettings(next);
|
||||||
|
setSaving(key);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await updateNotificationSettings(next);
|
||||||
|
} catch (err) {
|
||||||
|
setSettings(settings);
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
|
||||||
|
aria-label="بازگشت"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
||||||
|
</button>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">اعلانها 🔔</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
{!settings ? (
|
||||||
|
<FullScreenLoader />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary) leading-6 px-1">
|
||||||
|
این تنظیمات همین الان ذخیره میشن. سیستم ارسال اعلان بیرون از اپ (پوش/پیامک) هنوز
|
||||||
|
راهاندازی نشده؛ بهمحض فعال شدنش، دقیقاً همین ترجیحات رو رعایت میکنه.
|
||||||
|
</p>
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{ITEMS.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3.5 ${
|
||||||
|
i !== ITEMS.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[14px] font-bold text-(--color-content-primary)">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary)">{item.hint}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings[item.key]}
|
||||||
|
disabled={saving === item.key}
|
||||||
|
onChange={(v) => handleToggle(item.key, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Bell, Shield, HelpCircle, LogOut, ChevronLeft } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
|
const settingsLinks = [
|
||||||
|
{ icon: Bell, label: "اعلانها", href: "/settings/notifications" },
|
||||||
|
{ icon: Shield, label: "حریم خصوصی و امنیت", href: "/settings/privacy" },
|
||||||
|
{ icon: HelpCircle, label: "راهنما و پشتیبانی", href: "/settings/help" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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">
|
||||||
|
{settingsLinks.map((item, i) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
onClick={() => router.push(item.href)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3.5 text-right hover:bg-(--color-bg-secondary) transition-colors ${
|
||||||
|
i !== settingsLinks.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} className="text-(--color-content-secondary) shrink-0" />
|
||||||
|
<span className="flex-1 text-[14px] text-(--color-content-primary)">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { ChevronRight, ShieldCheck, Phone, MapPin, Lock, Trash2, KeyRound, TriangleAlert } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { deleteAccount } from "@/lib/api";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
|
||||||
|
function ConfirmDeleteSheet({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onCancel}
|
||||||
|
className="fixed inset-0 z-40 bg-black/40"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: "100%" }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: "100%" }}
|
||||||
|
transition={{ type: "spring", stiffness: 320, damping: 32 }}
|
||||||
|
className="fixed inset-x-0 bottom-0 z-50 bg-(--color-surface-default) rounded-t-(--radius-xl) shadow-(--shadow-cute-lg) px-6 pt-6 pb-[calc(env(safe-area-inset-bottom)+24px)]"
|
||||||
|
>
|
||||||
|
<h3 className="font-[var(--font-display)] font-extrabold text-[18px] mb-2">
|
||||||
|
حذف حساب کاربری
|
||||||
|
</h3>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) leading-6 mb-6">
|
||||||
|
مطمئنی میخوای حسابت رو حذف کنی؟ این کار رو نمیشه خودت برگردونی.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="lg" className="flex-1" onClick={onCancel}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
isLoading={loading}
|
||||||
|
className="flex-1 !bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
بله، حذف کن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTS = [
|
||||||
|
{
|
||||||
|
icon: <KeyRound size={16} className="text-(--color-brand-500)" />,
|
||||||
|
text: "پیامهای چتت با رمزنگاری سرتاسری (end-to-end) بین تو و طرف مقابل رد و بدل میشن؛ فقط با کلید روی گوشی/مرورگر شما دو نفر باز میشن. نه سرور، نه ادمینهای دلدل، نه هیچکس دیگهای نمیتونه بخوندشون — حتی اگه کسی وسط راه ترافیک شبکه رو گوش بده.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Phone size={16} className="text-(--color-brand-500)" />,
|
||||||
|
text: "شماره موبایلت هیچوقت به کاربرهای دیگه نشون داده نمیشه.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <MapPin size={16} className="text-(--color-brand-500)" />,
|
||||||
|
text: "موقعیت مکانی دقیقت هیچوقت ذخیره یا نشون داده نمیشه؛ فقط یه فاصلهی تقریبی و گردشده (مثلاً «۲۰ کیلومتر») محاسبه میشه.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Lock size={16} className="text-(--color-brand-500)" />,
|
||||||
|
text: "ورود فقط با کد یکبارمصرف پیامکیه؛ رمز عبوری برای دزدیده شدن وجود نداره.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ShieldCheck size={16} className="text-(--color-brand-500)" />,
|
||||||
|
text: "هر پروفایل قبل از دیده شدن توسط بقیه، توسط تیم پشتیبانی بررسی و تایید میشه.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PrivacySettingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteAccount();
|
||||||
|
logout();
|
||||||
|
router.push("/login");
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary)"
|
||||||
|
aria-label="بازگشت"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
||||||
|
</button>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
|
||||||
|
حریم خصوصی و امنیت 🔒
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
<Card className="flex flex-col gap-4">
|
||||||
|
{FACTS.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2.5">
|
||||||
|
<div className="mt-0.5 shrink-0">{f.icon}</div>
|
||||||
|
<p className="text-[13.5px] text-(--color-content-secondary) leading-6">{f.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex flex-col gap-2.5 border-2 !border-amber-100">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<TriangleAlert size={16} className="text-amber-600 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] font-bold text-(--color-content-primary)">
|
||||||
|
کلید رمزنگاری فقط روی همین دستگاهه
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary) mt-1 leading-6">
|
||||||
|
هیچ نسخهی پشتیبانی از کلیدهای رمزنگاری روی سرور نگه داشته نمیشه. اگه
|
||||||
|
کش/اطلاعات مرورگر رو پاک کنی یا از یه گوشی/مرورگر دیگه وارد بشی، پیامهای
|
||||||
|
قبلی برای همیشه غیرقابلخوندن میشن — همینجوری که توی واتساپ و سیگنال هم
|
||||||
|
همینه.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex flex-col gap-3 border-2 !border-red-100">
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] font-bold text-(--color-status-error)">حذف حساب کاربری</p>
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary) mt-1 leading-6">
|
||||||
|
حسابت غیرفعال میشه و دیگه نمیتونی وارد بشی یا توی دوستیابی دیده بشی. برای
|
||||||
|
برگردوندنش باید با پشتیبانی تماس بگیری.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
icon={<Trash2 size={15} />}
|
||||||
|
className="!text-(--color-status-error) !border-red-200 w-fit"
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
حذف حساب من
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmDeleteSheet
|
||||||
|
open={confirmOpen}
|
||||||
|
loading={deleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setConfirmOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 911 B |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"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 { PhotosStep } from "@/components/onboarding/steps/PhotosStep";
|
||||||
|
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 />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyOnboarded = !!onboardingStatus?.is_onboarded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingScreen
|
||||||
|
order={meta.order}
|
||||||
|
title={meta.title}
|
||||||
|
emoji={meta.emoji}
|
||||||
|
onBack={
|
||||||
|
meta.order === 1
|
||||||
|
? () => router.push(alreadyOnboarded ? "/home" : "/login")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onJumpToStep={alreadyOnboarded ? (s) => router.push(`/onboarding/${s}`) : 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 "photos":
|
||||||
|
return <PhotosStep 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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="دلدل داره باز میشه..." />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Flame } from "lucide-react";
|
||||||
|
|
||||||
|
// نشان استریک روزانه؛ تریگر بیرونی کلاسیک برای برگشت روزانهی کاربر (Hook Model).
|
||||||
|
// وقتی عدد عوض میشه یه بار کوچیک pop میزنه تا حس جایزه گرفتن بده.
|
||||||
|
export function StreakBadge({ count }: { count: number }) {
|
||||||
|
if (count <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.div
|
||||||
|
key={count}
|
||||||
|
initial={{ scale: 0.6, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
||||||
|
className="flex items-center gap-1 rounded-(--radius-full) bg-amber-50 border border-amber-200 px-2.5 py-1 text-amber-600"
|
||||||
|
>
|
||||||
|
<Flame size={13} fill="currentColor" className="text-amber-500" />
|
||||||
|
<span className="text-[12px] font-extrabold">{count.toLocaleString("fa-IR")}</span>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { RotateCcw, Trophy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
const GAME_DURATION = 20; // ثانیه
|
||||||
|
const SPAWN_MS = 650;
|
||||||
|
const HIGH_SCORE_KEY = "deldel_catch_hearts_high_score";
|
||||||
|
|
||||||
|
interface FallingHeart {
|
||||||
|
id: number;
|
||||||
|
x: number; // درصد از عرض
|
||||||
|
fallSeconds: number;
|
||||||
|
isBonus: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// وقتی دیگه پیشنهادی برای نشون دادن نیست، بهجای یه پیام خالی، یه بازی کوچیک
|
||||||
|
// میذاریم تا کاربر توی اپ بمونه («The Hunt» از مدل Hook — یه پاداش کوچیک و
|
||||||
|
// غیرقابلپیشبینی حتی وقتی فید خالیه).
|
||||||
|
export function CatchHeartsGame({ onExit }: { onExit: () => void }) {
|
||||||
|
const [state, setState] = useState<"idle" | "playing" | "ended">("idle");
|
||||||
|
const [hearts, setHearts] = useState<FallingHeart[]>([]);
|
||||||
|
const [score, setScore] = useState(0);
|
||||||
|
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
|
||||||
|
const [highScore, setHighScore] = useState(0);
|
||||||
|
const nextId = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = Number(localStorage.getItem(HIGH_SCORE_KEY) ?? 0);
|
||||||
|
if (!Number.isNaN(stored)) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setHighScore(stored);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "playing") return;
|
||||||
|
|
||||||
|
const spawnInterval = setInterval(() => {
|
||||||
|
const id = nextId.current++;
|
||||||
|
setHearts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
x: 8 + Math.random() * 80,
|
||||||
|
fallSeconds: 2.6 + Math.random() * 1.8,
|
||||||
|
isBonus: Math.random() < 0.15,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, SPAWN_MS);
|
||||||
|
|
||||||
|
const countdown = setInterval(() => {
|
||||||
|
setTimeLeft((t) => Math.max(0, t - 1));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(spawnInterval);
|
||||||
|
clearInterval(countdown);
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "playing" || timeLeft !== 0) return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setState("ended");
|
||||||
|
setHearts([]);
|
||||||
|
setScore((s) => {
|
||||||
|
if (s > highScore) {
|
||||||
|
localStorage.setItem(HIGH_SCORE_KEY, String(s));
|
||||||
|
setHighScore(s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}, [timeLeft, state, highScore]);
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
setScore(0);
|
||||||
|
setTimeLeft(GAME_DURATION);
|
||||||
|
setHearts([]);
|
||||||
|
setState("playing");
|
||||||
|
}
|
||||||
|
|
||||||
|
function catchHeart(heart: FallingHeart) {
|
||||||
|
setHearts((prev) => prev.filter((h) => h.id !== heart.id));
|
||||||
|
setScore((s) => s + (heart.isBonus ? 3 : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMissed(id: number) {
|
||||||
|
setHearts((prev) => prev.filter((h) => h.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "idle") {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center gap-3 text-center px-6">
|
||||||
|
<div className="text-[56px] [animation:wiggle_3s_ease-in-out_infinite]">💘</div>
|
||||||
|
<h3 className="font-[var(--font-display)] font-extrabold text-[19px] text-(--color-content-primary)">
|
||||||
|
فعلاً کسی نمونده!
|
||||||
|
</h3>
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary) max-w-[240px]">
|
||||||
|
تا پیشنهاد جدید بیاد، یه بازی کوچیک بزن: قلبها رو بگیر ببین چندتا امتیاز میگیری.
|
||||||
|
</p>
|
||||||
|
{highScore > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[12px] text-amber-600 font-bold">
|
||||||
|
<Trophy size={14} /> رکورد قبلیت: {highScore.toLocaleString("fa-IR")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button onClick={startGame}>شروع بازی 🎮</Button>
|
||||||
|
<button
|
||||||
|
onClick={onExit}
|
||||||
|
className="text-[12.5px] text-(--color-content-tertiary) underline mt-1"
|
||||||
|
>
|
||||||
|
بررسی مجدد پیشنهادها
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "playing") {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<div className="absolute top-0 inset-x-0 flex items-center justify-between px-2 z-10">
|
||||||
|
<span className="text-[13px] font-extrabold text-(--color-brand-600)">
|
||||||
|
امتیاز: {score.toLocaleString("fa-IR")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] font-bold text-(--color-content-tertiary)">
|
||||||
|
{timeLeft.toLocaleString("fa-IR")} ثانیه
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 pt-12 overflow-hidden">
|
||||||
|
<AnimatePresence>
|
||||||
|
{hearts.map((h) => (
|
||||||
|
<motion.button
|
||||||
|
key={h.id}
|
||||||
|
initial={{ top: "-8%" }}
|
||||||
|
animate={{ top: "100%" }}
|
||||||
|
transition={{ duration: h.fallSeconds, ease: "linear" }}
|
||||||
|
onAnimationComplete={() => removeMissed(h.id)}
|
||||||
|
onClick={() => catchHeart(h)}
|
||||||
|
whileTap={{ scale: 1.4 }}
|
||||||
|
style={{ position: "absolute", left: `${h.x}%` }}
|
||||||
|
className="text-[28px] leading-none cursor-pointer"
|
||||||
|
aria-label="گرفتن قلب"
|
||||||
|
>
|
||||||
|
{h.isBonus ? "💎" : "❤️"}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center gap-3 text-center px-6">
|
||||||
|
<div className="text-[48px]">{score > 0 && score >= highScore ? "🏆" : "💖"}</div>
|
||||||
|
<h3 className="font-[var(--font-display)] font-extrabold text-[19px]">
|
||||||
|
امتیازت: {score.toLocaleString("fa-IR")}
|
||||||
|
</h3>
|
||||||
|
{score > 0 && score >= highScore && (
|
||||||
|
<p className="text-[13px] text-amber-600 font-bold">رکورد جدید! 🎉</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button variant="tertiary" icon={<RotateCcw size={14} />} onClick={startGame}>
|
||||||
|
دوباره بازی کن
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onExit}>بررسی مجدد پیشنهادها</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"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;
|
||||||
|
unlimitedDistance?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [unlimitedDistance, setUnlimitedDistance] = useState(!!initial.unlimitedDistance);
|
||||||
|
|
||||||
|
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)">
|
||||||
|
{unlimitedDistance ? "همهجا" : `${maxDistance} کیلومتر`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={300}
|
||||||
|
value={maxDistance}
|
||||||
|
disabled={unlimitedDistance}
|
||||||
|
onChange={(e) => setMaxDistance(Number(e.target.value))}
|
||||||
|
className="w-full accent-(--color-brand-500) disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 mt-3 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={unlimitedDistance}
|
||||||
|
onChange={(e) => setUnlimitedDistance(e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-(--color-brand-500)"
|
||||||
|
/>
|
||||||
|
<span className="text-[13px] text-(--color-content-secondary)">
|
||||||
|
بدون محدودیت فاصله (توی همهجا بگرد)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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, unlimitedDistance });
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
اعمال فیلتر
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const CONFETTI_COLORS = ["#ff4d6d", "#ffd166", "#06d6a0", "#4cc9f0", "#f472b6", "#ffffff"];
|
||||||
|
|
||||||
|
// بارونِ ریز کاغذرنگی که با باز شدن مودال مچ میباره؛ سلولوزیِ کلاسیک "پاداش
|
||||||
|
// متغیر" (Variable Reward) لحظهی مچ رو حسمانیتر میکنه.
|
||||||
|
function Confetti() {
|
||||||
|
const [pieces] = useState(() =>
|
||||||
|
Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
x: Math.random() * 100,
|
||||||
|
delay: Math.random() * 0.4,
|
||||||
|
duration: 1.8 + Math.random() * 1.2,
|
||||||
|
rotate: Math.random() * 360,
|
||||||
|
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
|
||||||
|
size: 6 + Math.random() * 6,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
{pieces.map((p) => (
|
||||||
|
<motion.span
|
||||||
|
key={p.id}
|
||||||
|
initial={{ y: "-10vh", x: `${p.x}vw`, opacity: 1, rotate: 0 }}
|
||||||
|
animate={{ y: "110vh", rotate: p.rotate }}
|
||||||
|
transition={{ duration: p.duration, delay: p.delay, ease: "easeIn" }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: p.size,
|
||||||
|
height: p.size * 1.6,
|
||||||
|
backgroundColor: p.color,
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Confetti />
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"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>
|
||||||
|
)}
|
||||||
|
{profile.bio && (
|
||||||
|
<p className="text-[12.5px] text-white/85 leading-5 mt-1.5 line-clamp-2">
|
||||||
|
{profile.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X, Heart, Star } from "lucide-react";
|
||||||
|
import { SwipeCard } from "./SwipeCard";
|
||||||
|
import { MatchModal } from "./MatchModal";
|
||||||
|
import { CatchHeartsGame } from "./CatchHeartsGame";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { DiscoverCandidate, MatchView, swipe } from "@/lib/api";
|
||||||
|
|
||||||
|
// وقتی کاربر لایک میزنه چندتا قلب کوچیک از وسط دِک به بالا شناور میشن و محو
|
||||||
|
// میشن؛ یه تاییدِ حسی سریع برای اکشنی که همین الان انجام داد (نه فقط تغییر state).
|
||||||
|
function HeartBurst({ onDone }: { onDone: () => void }) {
|
||||||
|
const [hearts] = useState(() =>
|
||||||
|
Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
x: (Math.random() - 0.5) * 90,
|
||||||
|
delay: Math.random() * 0.15,
|
||||||
|
size: 16 + Math.random() * 16,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 pointer-events-none flex items-center justify-center z-20">
|
||||||
|
{hearts.map((h) => (
|
||||||
|
<motion.span
|
||||||
|
key={h.id}
|
||||||
|
initial={{ opacity: 1, scale: 0.4, x: h.x, y: 0 }}
|
||||||
|
animate={{ opacity: 0, scale: 1.15, y: -150 }}
|
||||||
|
transition={{ duration: 0.9, delay: h.delay, ease: "easeOut" }}
|
||||||
|
onAnimationComplete={h.id === hearts.length - 1 ? onDone : undefined}
|
||||||
|
style={{ position: "absolute", fontSize: h.size }}
|
||||||
|
>
|
||||||
|
❤️
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [burstId, setBurstId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
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));
|
||||||
|
if (direction === "right") {
|
||||||
|
setBurstId(Date.now());
|
||||||
|
}
|
||||||
|
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 ? (
|
||||||
|
<CatchHeartsGame onExit={onReload} />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{burstId !== null && <HeartBurst key={burstId} onDone={() => setBurstId(null)} />}
|
||||||
|
</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,84 @@
|
|||||||
|
"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;
|
||||||
|
// وقتی پروفایل قبلاً کامل شده (در انتظار بررسی یا ردشده)، کاربر میتونه
|
||||||
|
// مستقیم روی هر بخش از نوار پیشرفت بزنه و بین مراحل جابهجا بشه
|
||||||
|
onJumpToStep?: (slug: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingScreen({
|
||||||
|
order,
|
||||||
|
title,
|
||||||
|
emoji,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
onBack,
|
||||||
|
onJumpToStep,
|
||||||
|
}: OnboardingScreenProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const total = ONBOARDING_STEPS.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-dvh">
|
||||||
|
<div className="shrink-0 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) =>
|
||||||
|
onJumpToStep ? (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => onJumpToStep(s.slug)}
|
||||||
|
aria-label={s.title}
|
||||||
|
className={`h-1.5 flex-1 rounded-(--radius-full) transition-colors duration-300 cursor-pointer ${
|
||||||
|
s.order <= order ? "bg-(--color-brand-500)" : "bg-(--color-bg-tertiary)"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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="shrink-0 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,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ButtonHTMLAttributes } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
interface StepFooterButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دکمهی اصلی هر مرحلهی onboarding؛ چون داخل ناحیهی overflow-y-auto صفحه است،
|
||||||
|
// sticky bottom-0 نسبت به همون ناحیه چسبیده میمونه، نه اینکه ته لیست اسکرول بشه.
|
||||||
|
export function StepFooterButton({
|
||||||
|
children,
|
||||||
|
isLoading,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: StepFooterButtonProps) {
|
||||||
|
return (
|
||||||
|
<div className="sticky bottom-0 -mx-6 px-6 pt-4 pb-[calc(env(safe-area-inset-bottom)+16px)] bg-(--color-bg-primary) border-t border-(--color-border-subtle)">
|
||||||
|
<Button size="lg" isBlock isLoading={isLoading} disabled={disabled} {...props}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { StepFooterButton } from "@/components/onboarding/StepFooterButton";
|
||||||
|
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} />
|
||||||
|
|
||||||
|
<StepFooterButton disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||||
|
بریم مرحله بعد
|
||||||
|
</StepFooterButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { StepFooterButton } from "@/components/onboarding/StepFooterButton";
|
||||||
|
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} />
|
||||||
|
<StepFooterButton disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||||
|
بریم مرحله بعد
|
||||||
|
</StepFooterButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StepFooterButton } from "@/components/onboarding/StepFooterButton";
|
||||||
|
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} />
|
||||||
|
|
||||||
|
<StepFooterButton disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||||
|
بریم مرحله بعد
|
||||||
|
</StepFooterButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StepFooterButton } from "@/components/onboarding/StepFooterButton";
|
||||||
|
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} />
|
||||||
|
<StepFooterButton disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||||
|
بریم مرحله بعد
|
||||||
|
</StepFooterButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { StepFooterButton } from "@/components/onboarding/StepFooterButton";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { confirmPhotosStep, uploadPhoto, deletePhoto, photoUrl, FullProfile, Photo } from "@/lib/api";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { validatePhotoSize } from "@/lib/utils";
|
||||||
|
|
||||||
|
const MAX_PHOTOS = 6;
|
||||||
|
|
||||||
|
export function PhotosStep({
|
||||||
|
initialProfile,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
initialProfile: FullProfile | null;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
|
const [photos, setPhotos] = useState<Photo[]>(initialProfile?.photos ?? []);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
const sizeError = validatePhotoSize(file);
|
||||||
|
if (sizeError) {
|
||||||
|
setError(sizeError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const photo = await uploadPhoto(file);
|
||||||
|
setPhotos((prev) => [...prev, photo]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deletePhoto(id);
|
||||||
|
setPhotos((prev) => prev.filter((p) => p.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNext() {
|
||||||
|
if (photos.length === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await confirmPhotosStep();
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<p className="text-[13px] font-semibold text-(--color-content-secondary)">
|
||||||
|
حداقل ۱ عکس لازمه؛ هرچی بیشتر بذاری، شانس دیده شدنت بیشتره ({photos.length}/{MAX_PHOTOS})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{/* 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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
|
||||||
|
<StepFooterButton disabled={photos.length === 0} isLoading={loading} onClick={handleNext}>
|
||||||
|
بریم مرحله بعد
|
||||||
|
</StepFooterButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StepFooterButton } from "@/components/onboarding/StepFooterButton";
|
||||||
|
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 { ChevronRight, Pencil } from "lucide-react";
|
||||||
|
|
||||||
|
const REQUIRED = 3;
|
||||||
|
|
||||||
|
interface SlotAnswer {
|
||||||
|
questionId: number;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// این مرحله بهجای اینکه یهو ۳ تا سوال رو با هم جلوی کاربر بذاره (که خستهکننده
|
||||||
|
// و شلوغه)، یکییکی جلو میبره: اول انتخاب سوال، بعد جواب همون سوال، بعد سوال
|
||||||
|
// بعدی — دقیقاً مثل یه مصاحبهی کوتاه، نه یه فرم بلند.
|
||||||
|
export function ProfileQuestionsStep({
|
||||||
|
initialProfile,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
initialProfile: FullProfile | null;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
|
const [questions, setQuestions] = useState<ProfileQuestion[] | null>(null);
|
||||||
|
const [slots, setSlots] = useState<(SlotAnswer | null)[]>(() => {
|
||||||
|
const initial = initialProfile?.profile_answers.slice(0, REQUIRED) ?? [];
|
||||||
|
const arr: (SlotAnswer | null)[] = initial.map((a) => ({
|
||||||
|
questionId: a.question_id,
|
||||||
|
answer: a.answer,
|
||||||
|
}));
|
||||||
|
while (arr.length < REQUIRED) arr.push(null);
|
||||||
|
return arr;
|
||||||
|
});
|
||||||
|
const [activeSlot, setActiveSlot] = useState(() => {
|
||||||
|
const firstEmpty = slots.findIndex((s) => s === null);
|
||||||
|
return firstEmpty === -1 ? REQUIRED - 1 : firstEmpty;
|
||||||
|
});
|
||||||
|
const [draftAnswer, setDraftAnswer] = useState(slots[activeSlot]?.answer ?? "");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAvailableQuestions()
|
||||||
|
.then(setQuestions)
|
||||||
|
.catch((err) => setError(formatApiError(err)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!questions) return <FullScreenLoader label="سوالات رو میاریم..." />;
|
||||||
|
|
||||||
|
const usedQuestionIds = slots
|
||||||
|
.filter((s, i) => s && i !== activeSlot)
|
||||||
|
.map((s) => s!.questionId);
|
||||||
|
const currentQuestion = questions.find((q) => q.id === slots[activeSlot]?.questionId) ?? null;
|
||||||
|
const availableForSlot = questions.filter((q) => !usedQuestionIds.includes(q.id));
|
||||||
|
|
||||||
|
function saveDraftInto(list: (SlotAnswer | null)[]) {
|
||||||
|
const next = [...list];
|
||||||
|
if (next[activeSlot]) {
|
||||||
|
next[activeSlot] = { ...next[activeSlot]!, answer: draftAnswer.trim() };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickQuestion(id: number) {
|
||||||
|
setSlots((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[activeSlot] = { questionId: id, answer: "" };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDraftAnswer("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCurrentQuestion() {
|
||||||
|
setSlots((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[activeSlot] = null;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDraftAnswer("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToSlot(index: number) {
|
||||||
|
setSlots((prev) => saveDraftInto(prev));
|
||||||
|
setActiveSlot(index);
|
||||||
|
setDraftAnswer(slots[index]?.answer ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
setSlots((prev) => saveDraftInto(prev));
|
||||||
|
goToSlot(activeSlot + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLastSlot = activeSlot === REQUIRED - 1;
|
||||||
|
const canAdvance = currentQuestion !== null && draftAnswer.trim().length > 0;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const finalSlots = saveDraftInto(slots);
|
||||||
|
if (finalSlots.some((s) => !s || !s.answer)) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await submitProfileQuestions(
|
||||||
|
finalSlots.map((s) => ({ question_id: s!.questionId, answer: s!.answer }))
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{Array.from({ length: REQUIRED }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => (slots[i] || i < activeSlot) && goToSlot(i)}
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 rounded-(--radius-full) transition-all",
|
||||||
|
i === activeSlot
|
||||||
|
? "w-8 bg-(--color-brand-500)"
|
||||||
|
: slots[i]
|
||||||
|
? "w-4 bg-(--color-brand-200)"
|
||||||
|
: "w-4 bg-(--color-bg-tertiary)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] font-semibold text-(--color-content-secondary) text-center">
|
||||||
|
سوال {activeSlot + 1} از {REQUIRED}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{activeSlot > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => goToSlot(activeSlot - 1)}
|
||||||
|
className="flex items-center gap-1 text-[13px] text-(--color-content-tertiary) w-fit"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
سوال قبلی
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!currentQuestion ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary) px-1">
|
||||||
|
یکی از این سوالها رو انتخاب کن:
|
||||||
|
</p>
|
||||||
|
{availableForSlot.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q.id}
|
||||||
|
onClick={() => pickQuestion(q.id)}
|
||||||
|
className="text-right rounded-(--radius-md) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3.5 text-[14px] text-(--color-content-primary) hover:border-(--color-brand-300) transition-colors"
|
||||||
|
>
|
||||||
|
{q.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-1">
|
||||||
|
<label className="text-[14px] font-bold text-(--color-content-primary)">
|
||||||
|
{currentQuestion.text}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={clearCurrentQuestion}
|
||||||
|
className="flex items-center gap-1 text-[12px] font-semibold text-(--color-brand-600) shrink-0"
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
عوض کردن سوال
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
key={currentQuestion.id}
|
||||||
|
value={draftAnswer}
|
||||||
|
onChange={(e) => setDraftAnswer(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
autoFocus
|
||||||
|
placeholder="جواب بده..."
|
||||||
|
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
|
||||||
|
<StepFooterButton
|
||||||
|
disabled={!canAdvance}
|
||||||
|
isLoading={loading}
|
||||||
|
onClick={isLastSlot ? handleSubmit : goNext}
|
||||||
|
>
|
||||||
|
{isLastSlot ? "ثبت و ادامه" : "سوال بعدی"}
|
||||||
|
</StepFooterButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 active:scale-95",
|
||||||
|
"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:scale-100 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";
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export function Switch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative w-11 h-6 rounded-(--radius-full) transition-colors duration-200 shrink-0 disabled:opacity-50 cursor-pointer ${
|
||||||
|
checked ? "bg-(--color-brand-500)" : "bg-(--color-bg-tertiary)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* از insetInlineStart استفاده میکنیم (نه left/right) تا توی حالت RTL درست جابهجا بشه */}
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-(--shadow-cute-sm) transition-all duration-200"
|
||||||
|
style={{ insetInlineStart: checked ? "22px" : "2px" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
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;
|
||||||
|
bio?: string;
|
||||||
|
streak_count: number;
|
||||||
|
notify_matches: boolean;
|
||||||
|
notify_messages: boolean;
|
||||||
|
notify_likes: boolean;
|
||||||
|
public_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
bio?: string;
|
||||||
|
photos: string[];
|
||||||
|
distance_km?: number;
|
||||||
|
interests?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateAnswer {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateDetail {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
age: number;
|
||||||
|
bio?: string;
|
||||||
|
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_message_cipher_text?: string;
|
||||||
|
last_message_nonce?: string;
|
||||||
|
last_at?: string;
|
||||||
|
/** کلید عمومی X25519 طرف مقابل (base64)، برای رمزنگاری سرتاسری. اگه طرف مقابل هنوز کلید ثبت نکرده باشه خالیه. */
|
||||||
|
partner_public_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwipeResult {
|
||||||
|
matched: boolean;
|
||||||
|
match?: MatchView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageView {
|
||||||
|
id: number;
|
||||||
|
match_id: number;
|
||||||
|
sender_id: number;
|
||||||
|
/** فقط برای پیامهای قدیمیِ قبل از رمزنگاری سرتاسری پر میشه */
|
||||||
|
text?: string;
|
||||||
|
cipher_text?: string;
|
||||||
|
nonce?: 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// خودِ عکسها با uploadPhoto/deletePhoto آپلود میشن؛ این فقط مرحله رو تایید/رد میکنه
|
||||||
|
export function confirmPhotosStep() {
|
||||||
|
return authRequest<OnboardingStatus>("/onboarding/photos", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 updateBio(bio: string) {
|
||||||
|
return authRequest<null>("/profile/bio", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ bio }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckInResult {
|
||||||
|
streak_count: number;
|
||||||
|
is_new_today: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkIn() {
|
||||||
|
return authRequest<CheckInResult>("/profile/checkin", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSettings {
|
||||||
|
notify_matches: boolean;
|
||||||
|
notify_messages: boolean;
|
||||||
|
notify_likes: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNotificationSettings(settings: NotificationSettings) {
|
||||||
|
return authRequest<null>("/profile/notifications", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAccount() {
|
||||||
|
return authRequest<null>("/profile/me", { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- دوستیابی ----------------
|
||||||
|
|
||||||
|
export function getDiscoverFeed(params?: {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cipherText/nonce قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل
|
||||||
|
// رمزنگاری شدن (src/lib/e2ee.ts)؛ متن خام پیام هیچوقت به این تابع نمیرسه.
|
||||||
|
export function sendMessage(matchId: number, cipherText: string, nonce: string) {
|
||||||
|
return authRequest<MessageView>(`/matches/${matchId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ cipher_text: cipherText, nonce }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePublicKey(publicKey: string) {
|
||||||
|
return authRequest<null>("/profile/public-key", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ public_key: publicKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { tokenStore } from "./token-store";
|
||||||
|
import { ensureKeyPair } from "./e2ee";
|
||||||
|
import {
|
||||||
|
UserProfile,
|
||||||
|
OnboardingStatus,
|
||||||
|
verifyOtp as apiVerifyOtp,
|
||||||
|
getOnboardingStatus,
|
||||||
|
getProfileMe,
|
||||||
|
checkIn as apiCheckIn,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
type AuthStatus = "loading" | "guest" | "authenticated";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
status: AuthStatus;
|
||||||
|
user: UserProfile | null;
|
||||||
|
onboardingStatus: OnboardingStatus | null;
|
||||||
|
streak: number;
|
||||||
|
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 [streak, setStreak] = useState(0);
|
||||||
|
|
||||||
|
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);
|
||||||
|
setStreak(profile.user.streak_count);
|
||||||
|
setStatus("authenticated");
|
||||||
|
// یک بار در روز برای هر کاربر: استریک روزانه (گیمیفیکیشن) رو ثبت میکنه.
|
||||||
|
// fire-and-forget؛ اگه شکست بخوره چیزی توی تجربهی کاربر خراب نمیشه.
|
||||||
|
apiCheckIn()
|
||||||
|
.then((r) => setStreak(r.streak_count))
|
||||||
|
.catch(() => {});
|
||||||
|
// کلید رمزنگاری سرتاسری پیامها رو مطمئن میشه که آماده و با سرور sync شده؛
|
||||||
|
// fire-and-forget، چون چت هنوز باز نشده و لازم نیست منتظرش بمونیم.
|
||||||
|
ensureKeyPair().catch(() => {});
|
||||||
|
} 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);
|
||||||
|
setStreak(data.user.streak_count);
|
||||||
|
setStatus("authenticated");
|
||||||
|
// وضعیت کامل مراحل رو جدا میگیریم چون verify-otp فقط current_step/is_onboarded میده
|
||||||
|
const fullStatus = await getOnboardingStatus();
|
||||||
|
setOnboardingStatus(fullStatus);
|
||||||
|
apiCheckIn()
|
||||||
|
.then((r) => setStreak(r.streak_count))
|
||||||
|
.catch(() => {});
|
||||||
|
ensureKeyPair().catch(() => {});
|
||||||
|
return data.user;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
tokenStore.clear();
|
||||||
|
setUser(null);
|
||||||
|
setOnboardingStatus(null);
|
||||||
|
setStreak(0);
|
||||||
|
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);
|
||||||
|
setStreak(profile.user.streak_count);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
status,
|
||||||
|
user,
|
||||||
|
onboardingStatus,
|
||||||
|
streak,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshOnboardingStatus,
|
||||||
|
refreshUser,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth باید داخل AuthProvider استفاده بشه");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// رمزنگاری سرتاسری (end-to-end) پیامهای چت با NaCl box (X25519-XSalsa20-Poly1305).
|
||||||
|
// کلید خصوصی هیچوقت از این فایل بیرون نمیره و هیچوقت به سرور فرستاده نمیشه؛
|
||||||
|
// فقط توی localStorage همین مرورگر میمونه. یعنی اگه کاربر localStorage رو پاک
|
||||||
|
// کنه یا از یه مرورگر/دستگاه دیگه وارد بشه، کلید قبلی از بین میره و پیامهای
|
||||||
|
// قبلی دیگه قابل رمزگشایی نیستن — این یه تصمیم آگاهانهست، نه باگ: هیچ نسخهی
|
||||||
|
// پشتیبان یا escrow از کلید خصوصی روی سرور نگه داشته نمیشه.
|
||||||
|
import nacl from "tweetnacl";
|
||||||
|
import util from "tweetnacl-util";
|
||||||
|
import { updatePublicKey } from "./api";
|
||||||
|
|
||||||
|
const SECRET_KEY_STORAGE = "dating_app_e2ee_secret_key";
|
||||||
|
|
||||||
|
function isBrowser() {
|
||||||
|
return typeof window !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalKeyPair {
|
||||||
|
publicKey: Uint8Array;
|
||||||
|
secretKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocalSecretKey(): Uint8Array | null {
|
||||||
|
if (!isBrowser()) return null;
|
||||||
|
const stored = localStorage.getItem(SECRET_KEY_STORAGE);
|
||||||
|
if (!stored) return null;
|
||||||
|
try {
|
||||||
|
return util.decodeBase64(stored);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آیا این مرورگر از قبل کلید محلی داره؟ (بدون تولید کلید جدید) */
|
||||||
|
export function hasLocalKeyPair(): boolean {
|
||||||
|
return readLocalSecretKey() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** کلید محلی رو بدون هیچ درخواست شبکهای برمیگردونه؛ اگه هنوز ساخته نشده null. */
|
||||||
|
export function getLocalKeyPair(): LocalKeyPair | null {
|
||||||
|
const secretKey = readLocalSecretKey();
|
||||||
|
if (!secretKey) return null;
|
||||||
|
return nacl.box.keyPair.fromSecretKey(secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کلید محلی رو برمیگردونه؛ اگه وجود نداشته باشه یه جفتکلید تازه میسازه و
|
||||||
|
* توی localStorage ذخیره میکنه. در هر دو حالت، کلید عمومی رو (دوباره) با
|
||||||
|
* سرور sync میکنه تا اگه ثبت قبلی به هر دلیلی (قطعی شبکه و…) ناموفق بوده،
|
||||||
|
* این بار درست بشه.
|
||||||
|
*/
|
||||||
|
export async function ensureKeyPair(): Promise<LocalKeyPair> {
|
||||||
|
let secretKey = readLocalSecretKey();
|
||||||
|
let keyPair: nacl.BoxKeyPair;
|
||||||
|
|
||||||
|
if (secretKey) {
|
||||||
|
keyPair = nacl.box.keyPair.fromSecretKey(secretKey);
|
||||||
|
} else {
|
||||||
|
keyPair = nacl.box.keyPair();
|
||||||
|
secretKey = keyPair.secretKey;
|
||||||
|
if (isBrowser()) {
|
||||||
|
localStorage.setItem(SECRET_KEY_STORAGE, util.encodeBase64(secretKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updatePublicKey(util.encodeBase64(keyPair.publicKey));
|
||||||
|
} catch {
|
||||||
|
// آفلاین یا خطای موقت سرور؛ کلید محلی معتبره و دفعهی بعد دوباره sync میشه.
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پیام متنی رو با کلید عمومی طرف مقابل رمزنگاری میکنه. */
|
||||||
|
export function encryptMessage(
|
||||||
|
plainText: string,
|
||||||
|
theirPublicKeyB64: string,
|
||||||
|
mySecretKey: Uint8Array
|
||||||
|
): { cipherText: string; nonce: string } {
|
||||||
|
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||||
|
const messageBytes = util.decodeUTF8(plainText);
|
||||||
|
const theirPublicKey = util.decodeBase64(theirPublicKeyB64);
|
||||||
|
const box = nacl.box(messageBytes, nonce, theirPublicKey, mySecretKey);
|
||||||
|
return {
|
||||||
|
cipherText: util.encodeBase64(box),
|
||||||
|
nonce: util.encodeBase64(nonce),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پیام رمزنگاریشده رو رمزگشایی میکنه؛ اگه کلید جور نباشه null برمیگردونه. */
|
||||||
|
export function decryptMessage(
|
||||||
|
cipherTextB64: string,
|
||||||
|
nonceB64: string,
|
||||||
|
theirPublicKeyB64: string,
|
||||||
|
mySecretKey: Uint8Array
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const cipherText = util.decodeBase64(cipherTextB64);
|
||||||
|
const nonce = util.decodeBase64(nonceB64);
|
||||||
|
const theirPublicKey = util.decodeBase64(theirPublicKeyB64);
|
||||||
|
const opened = nacl.box.open(cipherText, nonce, theirPublicKey, mySecretKey);
|
||||||
|
if (!opened) return null;
|
||||||
|
return util.encodeUTF8(opened);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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: "photos", slug: "photos", order: 2, title: "چندتا عکس از خودت بذار", emoji: "📸" },
|
||||||
|
{ key: "body_info", slug: "body-info", order: 3, title: "قد و وزنت چقدره؟", emoji: "📏" },
|
||||||
|
{ key: "profile_questions", slug: "profile-questions", order: 4, title: "یکم از خودت بگو", emoji: "💬" },
|
||||||
|
{ key: "education", slug: "education", order: 5, title: "تحصیلاتت چیه؟", emoji: "🎓" },
|
||||||
|
{ key: "occupations", slug: "occupations", order: 6, title: "چیکار میکنی؟", emoji: "💼" },
|
||||||
|
{
|
||||||
|
key: "relationship_importance",
|
||||||
|
slug: "relationship-importance",
|
||||||
|
order: 7,
|
||||||
|
title: "توی رابطه چی برات مهمه؟",
|
||||||
|
emoji: "❤️",
|
||||||
|
},
|
||||||
|
{ key: "interests", slug: "interests", order: 8, title: "به چی علاقه داری؟", emoji: "✨" },
|
||||||
|
{ key: "looking_for", slug: "looking-for", order: 9, 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 const MAX_PHOTO_SIZE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function validatePhotoSize(file: File): string | null {
|
||||||
|
if (file.size > MAX_PHOTO_SIZE_BYTES) {
|
||||||
|
return "حجم عکس نباید بیشتر از ۱۰ مگابایت باشه؛ یه عکس کوچیکتر انتخاب کن.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcAge(birthDate?: string): number | null {
|
||||||
|
if (!birthDate) return null;
|
||||||
|
const b = new Date(birthDate);
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user