Initial commit: dating app web frontend (Next.js)
Phone+OTP auth, staged onboarding, admin-approval-gated home screen, and the full discovery/match/chat UI (swipe deck with drag gestures, match modal, profile detail, filters, messages/chat) wired to the real backend, styled after a Figma dating-app UI kit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
export interface DiscoverFilters {
|
||||
maxDistance: number;
|
||||
minAge: number;
|
||||
maxAge: number;
|
||||
}
|
||||
|
||||
export function FiltersSheet({
|
||||
open,
|
||||
initial,
|
||||
onClose,
|
||||
onApply,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial: DiscoverFilters;
|
||||
onClose: () => void;
|
||||
onApply: (filters: DiscoverFilters) => void;
|
||||
}) {
|
||||
const [maxDistance, setMaxDistance] = useState(initial.maxDistance);
|
||||
const [minAge, setMinAge] = useState(initial.minAge);
|
||||
const [maxAge, setMaxAge] = useState(initial.maxAge);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-40 bg-black/40"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 32 }}
|
||||
className="fixed inset-x-0 bottom-0 z-50 bg-(--color-surface-default) rounded-t-(--radius-xl) shadow-(--shadow-cute-lg) px-6 pt-5 pb-[calc(env(safe-area-inset-bottom)+24px)]"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="font-[var(--font-display)] font-extrabold text-[19px]">فیلترها</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] font-bold text-(--color-content-primary)">
|
||||
حداکثر فاصله
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-(--color-brand-600)">
|
||||
{maxDistance} کیلومتر
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={300}
|
||||
value={maxDistance}
|
||||
onChange={(e) => setMaxDistance(Number(e.target.value))}
|
||||
className="w-full accent-(--color-brand-500)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] font-bold text-(--color-content-primary)">
|
||||
محدودهی سنی
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-(--color-brand-600)">
|
||||
{minAge} تا {maxAge} سال
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={18}
|
||||
max={maxAge}
|
||||
value={minAge}
|
||||
onChange={(e) => setMinAge(Math.min(Number(e.target.value), maxAge))}
|
||||
className="w-full accent-(--color-brand-500)"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={minAge}
|
||||
max={60}
|
||||
value={maxAge}
|
||||
onChange={(e) => setMaxAge(Math.max(Number(e.target.value), minAge))}
|
||||
className="w-full accent-(--color-brand-500)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
isBlock
|
||||
className="mt-8"
|
||||
onClick={() => {
|
||||
onApply({ maxDistance, minAge, maxAge });
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
اعمال فیلتر
|
||||
</Button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { MatchView, photoUrl } from "@/lib/api";
|
||||
|
||||
export function MatchModal({
|
||||
match,
|
||||
onClose,
|
||||
}: {
|
||||
match: MatchView | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const photo = match?.photos[0];
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{match && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/70 px-8 text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 18, delay: 0.1 }}
|
||||
className="w-28 h-28 rounded-full border-4 border-white overflow-hidden shadow-(--shadow-cute-lg) mb-6 bg-(--color-bg-tertiary) flex items-center justify-center"
|
||||
>
|
||||
{photo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={photoUrl(photo)} alt={match.first_name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User size={40} className="text-(--color-content-disabled)" />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="font-[var(--font-display)] font-extrabold text-[30px] text-white"
|
||||
>
|
||||
یه مچ جدید داری، {match.first_name}! 🎉
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-[14px] text-white/80 mt-2 max-w-xs"
|
||||
>
|
||||
هردوتون همدیگه رو لایک کردین. الان وقت خوبیه که یه سلام بدی.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="flex flex-col gap-3 w-full max-w-xs mt-10"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
isBlock
|
||||
onClick={() => {
|
||||
onClose();
|
||||
router.push(`/messages/${match.id}`);
|
||||
}}
|
||||
>
|
||||
سلام بده 👋
|
||||
</Button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-12 text-[14px] font-bold text-white/80 hover:text-white"
|
||||
>
|
||||
به کشف کردن ادامه بده
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, useMotionValue, useTransform, PanInfo } from "framer-motion";
|
||||
import { MapPin, User } from "lucide-react";
|
||||
import { DiscoverCandidate, photoUrl } from "@/lib/api";
|
||||
import { formatDistance } from "@/lib/utils";
|
||||
|
||||
const SWIPE_THRESHOLD = 120;
|
||||
|
||||
export function SwipeCard({
|
||||
profile,
|
||||
isTop,
|
||||
stackIndex,
|
||||
onDecide,
|
||||
}: {
|
||||
profile: DiscoverCandidate;
|
||||
isTop: boolean;
|
||||
stackIndex: number;
|
||||
onDecide: (direction: "left" | "right") => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const x = useMotionValue(0);
|
||||
const rotate = useTransform(x, [-260, 260], [-16, 16]);
|
||||
const likeOpacity = useTransform(x, [20, 120], [0, 1]);
|
||||
const nopeOpacity = useTransform(x, [-120, -20], [1, 0]);
|
||||
|
||||
function handleDragEnd(_: unknown, info: PanInfo) {
|
||||
if (info.offset.x > SWIPE_THRESHOLD) {
|
||||
onDecide("right");
|
||||
} else if (info.offset.x < -SWIPE_THRESHOLD) {
|
||||
onDecide("left");
|
||||
}
|
||||
}
|
||||
|
||||
const photo = profile.photos[0];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={
|
||||
isTop
|
||||
? { x, rotate }
|
||||
: {
|
||||
scale: 1 - stackIndex * 0.04,
|
||||
y: stackIndex * 10,
|
||||
}
|
||||
}
|
||||
drag={isTop ? "x" : false}
|
||||
dragElastic={0.9}
|
||||
onDragEnd={isTop ? handleDragEnd : undefined}
|
||||
initial={{ scale: 1 - stackIndex * 0.04, y: stackIndex * 10, opacity: 0 }}
|
||||
animate={{ scale: 1 - stackIndex * 0.04, y: stackIndex * 10, opacity: 1 }}
|
||||
exit={{
|
||||
x: x.get() > 0 ? 400 : x.get() < 0 ? -400 : 0,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<div
|
||||
onClick={() => isTop && router.push(`/home/${profile.id}`)}
|
||||
className="relative w-full h-full rounded-(--radius-xl) overflow-hidden shadow-(--shadow-cute-lg) bg-(--color-bg-tertiary) cursor-pointer select-none"
|
||||
>
|
||||
{photo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={photoUrl(photo)}
|
||||
alt={profile.first_name}
|
||||
draggable={false}
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-(--color-bg-tertiary) text-(--color-content-disabled) pointer-events-none">
|
||||
<User size={64} strokeWidth={1.2} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none" />
|
||||
|
||||
{isTop && (
|
||||
<>
|
||||
<motion.span
|
||||
style={{ opacity: likeOpacity }}
|
||||
className="absolute top-8 right-6 rotate-[-12deg] border-4 border-(--color-status-success) text-(--color-status-success) font-[var(--font-display)] font-extrabold text-[26px] px-3 py-1 rounded-(--radius-sm) bg-white/70"
|
||||
>
|
||||
لایک
|
||||
</motion.span>
|
||||
<motion.span
|
||||
style={{ opacity: nopeOpacity }}
|
||||
className="absolute top-8 left-6 rotate-[12deg] border-4 border-(--color-status-error) text-(--color-status-error) font-[var(--font-display)] font-extrabold text-[26px] px-3 py-1 rounded-(--radius-sm) bg-white/70"
|
||||
>
|
||||
ردّ
|
||||
</motion.span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 p-5 pointer-events-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-[var(--font-display)] font-extrabold text-[24px] text-white">
|
||||
{profile.first_name || "بدون نام"}
|
||||
</h2>
|
||||
<span className="font-[var(--font-display)] font-bold text-[20px] text-white/90">
|
||||
{profile.age}
|
||||
</span>
|
||||
</div>
|
||||
{profile.distance_km != null && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
<MapPin size={14} className="text-white/80" />
|
||||
<span className="text-[13px] text-white/80">
|
||||
{formatDistance(profile.distance_km)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X, Heart, Star, RotateCcw } from "lucide-react";
|
||||
import { SwipeCard } from "./SwipeCard";
|
||||
import { MatchModal } from "./MatchModal";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { DiscoverCandidate, MatchView, swipe } from "@/lib/api";
|
||||
|
||||
export function SwipeDeck({
|
||||
profiles,
|
||||
onReload,
|
||||
}: {
|
||||
profiles: DiscoverCandidate[];
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const [deck, setDeck] = useState<DiscoverCandidate[]>(profiles);
|
||||
const [matched, setMatched] = useState<MatchView | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function decide(direction: "left" | "right", superlike = false) {
|
||||
if (deck.length === 0 || busy) return;
|
||||
const top = deck[0];
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setDeck((prev) => prev.slice(1));
|
||||
try {
|
||||
const action = direction === "left" ? "pass" : superlike ? "superlike" : "like";
|
||||
const result = await swipe(top.id, action);
|
||||
if (result.matched && result.match) {
|
||||
setMatched(result.match);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const visible = deck.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col px-5 pb-6">
|
||||
{error && (
|
||||
<div className="pb-3">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1">
|
||||
{visible.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<EmptyState
|
||||
icon={<RotateCcw size={32} />}
|
||||
title="فعلاً همه رو دیدی"
|
||||
description="پروفایل جدیدی برای نشون دادن نداریم. بعداً دوباره سر بزن."
|
||||
/>
|
||||
<Button size="sm" onClick={onReload}>
|
||||
بررسی مجدد
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
{[...visible].reverse().map((profile, idxFromEnd) => {
|
||||
const stackIndex = visible.length - 1 - idxFromEnd;
|
||||
return (
|
||||
<SwipeCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isTop={stackIndex === 0}
|
||||
stackIndex={stackIndex}
|
||||
onDecide={decide}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visible.length > 0 && (
|
||||
<div className="flex items-center justify-center gap-5 pt-6">
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.85 }}
|
||||
onClick={() => decide("left")}
|
||||
disabled={busy}
|
||||
className="w-14 h-14 rounded-full bg-white flex items-center justify-center shadow-(--shadow-float) text-(--color-status-error) disabled:opacity-50"
|
||||
aria-label="رد کردن"
|
||||
>
|
||||
<X size={26} strokeWidth={2.5} />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.85 }}
|
||||
onClick={() => decide("right", true)}
|
||||
disabled={busy}
|
||||
className="w-9 h-9 rounded-full bg-white flex items-center justify-center shadow-(--shadow-float) text-(--color-pop-purple) disabled:opacity-50"
|
||||
aria-label="سوپرلایک"
|
||||
>
|
||||
<Star size={16} strokeWidth={2.5} />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.85 }}
|
||||
onClick={() => decide("right")}
|
||||
disabled={busy}
|
||||
className="w-16 h-16 rounded-full bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-md) text-white disabled:opacity-50"
|
||||
aria-label="لایک"
|
||||
>
|
||||
<Heart size={28} fill="white" />
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MatchModal match={matched} onClose={() => setMatched(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ONBOARDING_STEPS } from "@/lib/onboarding-steps";
|
||||
|
||||
interface OnboardingScreenProps {
|
||||
order: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingScreen({
|
||||
order,
|
||||
title,
|
||||
emoji,
|
||||
description,
|
||||
children,
|
||||
onBack,
|
||||
}: OnboardingScreenProps) {
|
||||
const router = useRouter();
|
||||
const total = ONBOARDING_STEPS.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-dvh">
|
||||
<div className="flex items-center gap-3 px-5 pt-[calc(env(safe-area-inset-top)+16px)] pb-2">
|
||||
<button
|
||||
onClick={onBack ?? (() => router.back())}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) shrink-0"
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<ChevronRight size={20} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
<div className="flex-1 flex gap-1.5">
|
||||
{ONBOARDING_STEPS.map((s) => (
|
||||
<div
|
||||
key={s.key}
|
||||
className={`h-1.5 flex-1 rounded-(--radius-full) transition-colors duration-300 ${
|
||||
s.order <= order ? "bg-(--color-brand-500)" : "bg-(--color-bg-tertiary)"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-(--color-content-tertiary) shrink-0">
|
||||
{order}/{total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-2">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px] text-(--color-content-primary) leading-snug">
|
||||
{title} <span>{emoji}</span>
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-[14px] text-(--color-content-secondary) leading-6">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-6 py-4 overflow-y-auto pb-[calc(env(safe-area-inset-bottom)+24px)]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/TextField";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { submitBasicInfo, FullProfile } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
|
||||
export function BasicInfoStep({
|
||||
initialProfile,
|
||||
onNext,
|
||||
}: {
|
||||
initialProfile: FullProfile | null;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const [gender, setGender] = useState<"male" | "female" | null>(
|
||||
initialProfile?.user.gender ?? null
|
||||
);
|
||||
const [firstName, setFirstName] = useState(initialProfile?.user.first_name ?? "");
|
||||
const [birthDate, setBirthDate] = useState(initialProfile?.user.birth_date ?? "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isValid = gender && firstName.trim().length >= 2 && birthDate;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid || !gender) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submitBasicInfo({ gender, first_name: firstName.trim(), birth_date: birthDate });
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||
جنسیت
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(["female", "male"] as const).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setGender(g)}
|
||||
className={cn(
|
||||
"h-14 rounded-(--radius-md) border-2 font-bold text-[15px] transition-all",
|
||||
gender === g
|
||||
? "bg-(--color-brand-500) border-(--color-brand-500) text-white shadow-(--shadow-cute-sm)"
|
||||
: "bg-(--color-surface-default) border-(--color-border-default) text-(--color-content-secondary)"
|
||||
)}
|
||||
>
|
||||
{g === "female" ? "زن 👩" : "مرد 👨"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="اسم کوچیکت چیه؟"
|
||||
placeholder="مثلاً سارا"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="تاریخ تولد"
|
||||
type="date"
|
||||
value={birthDate}
|
||||
onChange={(e) => setBirthDate(e.target.value)}
|
||||
/>
|
||||
|
||||
<ErrorBanner message={error} />
|
||||
|
||||
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||
بریم مرحله بعد
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/TextField";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { submitBodyInfo, FullProfile } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
|
||||
export function BodyInfoStep({
|
||||
initialProfile,
|
||||
onNext,
|
||||
}: {
|
||||
initialProfile: FullProfile | null;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const [height, setHeight] = useState(
|
||||
initialProfile?.user.height_cm?.toString() ?? ""
|
||||
);
|
||||
const [weight, setWeight] = useState(
|
||||
initialProfile?.user.weight_kg?.toString() ?? ""
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const h = parseInt(height, 10);
|
||||
const w = parseInt(weight, 10);
|
||||
const isValid = h >= 100 && h <= 250 && w >= 30 && w <= 250;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submitBodyInfo({ height_cm: h, weight_kg: w });
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<TextField
|
||||
label="قد (سانتیمتر)"
|
||||
type="number"
|
||||
placeholder="مثلاً 172"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="وزن (کیلوگرم)"
|
||||
type="number"
|
||||
placeholder="مثلاً 65"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
<ErrorBanner message={error} />
|
||||
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||
بریم مرحله بعد
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { OptionPicker } from "@/components/ui/OptionPicker";
|
||||
import { getOptions, submitEducation, Option, FullProfile } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
|
||||
function findSelection(profile: FullProfile | null, key: string): number[] {
|
||||
return profile?.selections.find((s) => s.category_key === key)?.option_ids ?? [];
|
||||
}
|
||||
|
||||
export function EducationStep({
|
||||
initialProfile,
|
||||
onNext,
|
||||
}: {
|
||||
initialProfile: FullProfile | null;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const [levelOptions, setLevelOptions] = useState<Option[] | null>(null);
|
||||
const [fieldOptions, setFieldOptions] = useState<Option[] | null>(null);
|
||||
const [levelId, setLevelId] = useState<number[]>(
|
||||
findSelection(initialProfile, "education_level")
|
||||
);
|
||||
const [fieldId, setFieldId] = useState<number[]>(
|
||||
findSelection(initialProfile, "field_of_study")
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getOptions("education_level"), getOptions("field_of_study")])
|
||||
.then(([lvl, field]) => {
|
||||
setLevelOptions(lvl);
|
||||
setFieldOptions(field);
|
||||
})
|
||||
.catch((err) => setError(formatApiError(err)));
|
||||
}, []);
|
||||
|
||||
const isValid = levelId.length === 1 && fieldId.length === 1;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submitEducation({
|
||||
education_level_option_id: levelId[0],
|
||||
field_of_study_option_id: fieldId[0],
|
||||
});
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!levelOptions || !fieldOptions) return <FullScreenLoader label="گزینهها رو میاریم..." />;
|
||||
|
||||
if (levelOptions.length === 0 || fieldOptions.length === 0) {
|
||||
return (
|
||||
<ErrorBanner message="هنوز گزینهای برای تحصیلات توی پنل ادمین ثبت نشده. اول باید ادمین اینها رو اضافه کنه." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-7">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||
سطح تحصیلات
|
||||
</label>
|
||||
<OptionPicker options={levelOptions} selectedIds={levelId} onChange={setLevelId} mode="single" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||
رشته تحصیلی
|
||||
</label>
|
||||
<OptionPicker options={fieldOptions} selectedIds={fieldId} onChange={setFieldId} mode="single" />
|
||||
</div>
|
||||
|
||||
<ErrorBanner message={error} />
|
||||
|
||||
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||
بریم مرحله بعد
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { OptionPicker } from "@/components/ui/OptionPicker";
|
||||
import { getOptions, CategoryKey, FullProfile } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
|
||||
interface GenericOptionStepProps {
|
||||
initialProfile: FullProfile | null;
|
||||
onNext: () => void;
|
||||
category: CategoryKey;
|
||||
mode: "single" | "multi";
|
||||
min: number;
|
||||
max?: number;
|
||||
submitFn: (ids: number[]) => Promise<unknown>;
|
||||
helperText?: string;
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
export function GenericOptionStep({
|
||||
initialProfile,
|
||||
onNext,
|
||||
category,
|
||||
mode,
|
||||
min,
|
||||
max,
|
||||
submitFn,
|
||||
helperText,
|
||||
emptyMessage,
|
||||
}: GenericOptionStepProps) {
|
||||
const [options, setOptions] = useState<
|
||||
Awaited<ReturnType<typeof getOptions>> | null
|
||||
>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>(
|
||||
initialProfile?.selections.find((s) => s.category_key === category)?.option_ids ?? []
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getOptions(category)
|
||||
.then(setOptions)
|
||||
.catch((err) => setError(formatApiError(err)));
|
||||
}, [category]);
|
||||
|
||||
const isValid = selectedIds.length >= min && (max ? selectedIds.length <= max : true);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submitFn(selectedIds);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options) return <FullScreenLoader label="گزینهها رو میاریم..." />;
|
||||
|
||||
if (options.length === 0) {
|
||||
return <ErrorBanner message={emptyMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{helperText && (
|
||||
<p className="text-[13px] font-semibold text-(--color-content-secondary)">
|
||||
{helperText} {max ? `(${selectedIds.length}/${max})` : ""}
|
||||
</p>
|
||||
)}
|
||||
<OptionPicker
|
||||
options={options}
|
||||
selectedIds={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
mode={mode}
|
||||
max={max}
|
||||
/>
|
||||
<ErrorBanner message={error} />
|
||||
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||
بریم مرحله بعد
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getAvailableQuestions,
|
||||
submitProfileQuestions,
|
||||
ProfileQuestion,
|
||||
FullProfile,
|
||||
} from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
const REQUIRED = 3;
|
||||
|
||||
export function ProfileQuestionsStep({
|
||||
initialProfile,
|
||||
onNext,
|
||||
}: {
|
||||
initialProfile: FullProfile | null;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const [questions, setQuestions] = useState<ProfileQuestion[] | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>(
|
||||
initialProfile?.profile_answers.map((a) => a.question_id) ?? []
|
||||
);
|
||||
const [answers, setAnswers] = useState<Record<number, string>>(() => {
|
||||
const map: Record<number, string> = {};
|
||||
initialProfile?.profile_answers.forEach((a) => {
|
||||
map[a.question_id] = a.answer;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAvailableQuestions()
|
||||
.then(setQuestions)
|
||||
.catch((err) => setError(formatApiError(err)));
|
||||
}, []);
|
||||
|
||||
function toggleQuestion(id: number) {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||
if (prev.length >= REQUIRED) return prev;
|
||||
return [...prev, id];
|
||||
});
|
||||
}
|
||||
|
||||
const allAnswered =
|
||||
selectedIds.length === REQUIRED &&
|
||||
selectedIds.every((id) => (answers[id] ?? "").trim().length > 0);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!allAnswered) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submitProfileQuestions(
|
||||
selectedIds.map((id) => ({ question_id: id, answer: answers[id].trim() }))
|
||||
);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!questions) return <FullScreenLoader label="سوالات رو میاریم..." />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-[13px] font-semibold text-(--color-content-secondary)">
|
||||
دقیقاً {REQUIRED} تا انتخاب کن ({selectedIds.length}/{REQUIRED})
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{questions.map((q) => {
|
||||
const selected = selectedIds.includes(q.id);
|
||||
const disabled = !selected && selectedIds.length >= REQUIRED;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => toggleQuestion(q.id)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex items-center gap-3 text-right rounded-(--radius-md) border-2 px-4 py-3.5 transition-all",
|
||||
selected
|
||||
? "border-(--color-brand-500) bg-(--color-brand-50)"
|
||||
: "border-(--color-border-default) bg-(--color-surface-default)",
|
||||
disabled && "opacity-40"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center shrink-0 border-2",
|
||||
selected
|
||||
? "bg-(--color-brand-500) border-(--color-brand-500)"
|
||||
: "border-(--color-border-default)"
|
||||
)}
|
||||
>
|
||||
{selected && <Check size={14} className="text-white" strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="text-[14px] text-(--color-content-primary)">{q.text}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
{selectedIds.map((id) => {
|
||||
const q = questions.find((qq) => qq.id === id);
|
||||
if (!q) return null;
|
||||
return (
|
||||
<div key={id} className="flex flex-col gap-1.5">
|
||||
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||
{q.text}
|
||||
</label>
|
||||
<textarea
|
||||
value={answers[id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAnswers((prev) => ({ ...prev, [id]: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
placeholder="جواب بده..."
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ErrorBanner message={error} />
|
||||
|
||||
<Button size="lg" isBlock disabled={!allAnswered} isLoading={loading} onClick={handleSubmit}>
|
||||
بریم مرحله بعد
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-(--color-brand-200)",
|
||||
"disabled:opacity-45 disabled:pointer-events-none disabled:translate-y-0 disabled:shadow-none",
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
isBlock && "w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -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,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";
|
||||
Reference in New Issue
Block a user