From 680db7dce0feca2708edb69e541beb30abef0e19 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Wed, 7 Oct 2026 19:40:26 +0330 Subject: [PATCH] feat(frontend): add shared client libraries and UI primitives Introduce API helpers, types, hooks, design-system components, and the authenticated app shell. --- frontend/src/components/app-shell.tsx | 153 +++++++ frontend/src/components/apple-emoji.tsx | 132 ++++++ frontend/src/components/brand-mark.tsx | 69 +++ frontend/src/components/empty-state.tsx | 36 ++ frontend/src/components/error-boundary.tsx | 57 +++ frontend/src/components/navbar.tsx | 222 ++++++++++ frontend/src/components/page-header.tsx | 42 ++ frontend/src/components/page-transition.tsx | 27 ++ frontend/src/components/panel.tsx | 55 +++ frontend/src/components/providers.tsx | 25 ++ frontend/src/components/reveal.tsx | 29 ++ frontend/src/components/theme-toggle.tsx | 21 + frontend/src/components/ui/avatar.tsx | 42 ++ frontend/src/components/ui/badge.tsx | 32 ++ frontend/src/components/ui/button.tsx | 45 ++ frontend/src/components/ui/card.tsx | 53 +++ frontend/src/components/ui/dialog.tsx | 97 +++++ frontend/src/components/ui/dropdown-menu.tsx | 43 ++ frontend/src/components/ui/input.tsx | 48 +++ .../components/ui/jalali-datetime-picker.tsx | 73 ++++ frontend/src/components/ui/label.tsx | 20 + frontend/src/components/ui/password-input.tsx | 46 ++ frontend/src/components/ui/progress.tsx | 38 ++ frontend/src/components/ui/skeleton.tsx | 13 + frontend/src/components/ui/switch.tsx | 29 ++ frontend/src/components/ui/tabs.tsx | 51 +++ frontend/src/components/ui/textarea.tsx | 19 + frontend/src/components/ui/user-avatar.tsx | 75 ++++ frontend/src/hooks/use-live-user.ts | 44 ++ frontend/src/hooks/use-realtime.ts | 82 ++++ frontend/src/lib/api.ts | 270 ++++++++++++ frontend/src/lib/auth-store.ts | 63 +++ frontend/src/lib/hearts.ts | 32 ++ frontend/src/lib/motion.ts | 37 ++ frontend/src/lib/query-client.ts | 45 ++ frontend/src/lib/types.ts | 408 ++++++++++++++++++ frontend/src/lib/upload.ts | 70 +++ frontend/src/lib/utils.ts | 30 ++ frontend/src/lib/validation.ts | 133 ++++++ 39 files changed, 2806 insertions(+) create mode 100644 frontend/src/components/app-shell.tsx create mode 100644 frontend/src/components/apple-emoji.tsx create mode 100644 frontend/src/components/brand-mark.tsx create mode 100644 frontend/src/components/empty-state.tsx create mode 100644 frontend/src/components/error-boundary.tsx create mode 100644 frontend/src/components/navbar.tsx create mode 100644 frontend/src/components/page-header.tsx create mode 100644 frontend/src/components/page-transition.tsx create mode 100644 frontend/src/components/panel.tsx create mode 100644 frontend/src/components/providers.tsx create mode 100644 frontend/src/components/reveal.tsx create mode 100644 frontend/src/components/theme-toggle.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/jalali-datetime-picker.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/password-input.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/ui/user-avatar.tsx create mode 100644 frontend/src/hooks/use-live-user.ts create mode 100644 frontend/src/hooks/use-realtime.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth-store.ts create mode 100644 frontend/src/lib/hearts.ts create mode 100644 frontend/src/lib/motion.ts create mode 100644 frontend/src/lib/query-client.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/lib/upload.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/lib/validation.ts diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx new file mode 100644 index 0000000..59945ae --- /dev/null +++ b/frontend/src/components/app-shell.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Navbar } from "@/components/navbar"; +import { ChatWidget } from "@/components/mentor-chat"; +import { BrandMark } from "@/components/brand-mark"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth, useAuthHydrated } from "@/lib/auth-store"; +import { http } from "@/lib/api"; +import type { User } from "@/lib/types"; +import { hasInfiniteHearts } from "@/lib/hearts"; + +function isPublic(p: string) { + return p === "/" || p === "/login"; +} + +const PROTECTED_PREFIXES = [ + "/cap", + "/unwrap", + "/leaderboard", + "/admin", + "/player", + "/quiz", + "/notifications", + "/lockout", +]; + +function needsAuth(p: string) { + if (isPublic(p)) return false; + return PROTECTED_PREFIXES.some((prefix) => p === prefix || p.startsWith(prefix + "/")); +} + +export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const hydrated = useAuthHydrated(); + const { user, setUser, accessToken, clear } = useAuth(); + const [bootDone, setBootDone] = useState(false); + + // Wait for persist hydration, then optionally refresh /me + useEffect(() => { + if (!hydrated) return; + let cancelled = false; + const boot = async () => { + if (!accessToken) { + if (!cancelled) setBootDone(true); + return; + } + try { + const { user: me } = await http.get<{ user: User }>("/api/auth/me"); + if (!cancelled) setUser(me); + } catch { + if (!cancelled) clear(); + } finally { + if (!cancelled) setBootDone(true); + } + }; + void boot(); + return () => { + cancelled = true; + }; + }, [hydrated, accessToken, clear, setUser]); + + const ready = hydrated && bootDone; + const requiresAuth = needsAuth(pathname); + + // Redirects only in effects — never during render + useEffect(() => { + if (!ready) return; + if (!user && requiresAuth) { + router.replace("/login"); + return; + } + if (user && pathname === "/login") { + router.replace("/cap"); + return; + } + if ( + user?.isLocked && + !hasInfiniteHearts(user.role) && + pathname !== "/lockout" && + pathname !== "/unwrap" && + requiresAuth + ) { + router.replace("/lockout"); + } + }, [ready, user, requiresAuth, pathname, router]); + + if (!ready) { + return ( +
+
+
+
+
+ + +
+
+ +
+
+
+
+
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+
+ ); + } + + // Protected routes: never mount children when logged out. Unknown URLs (404) stay visible. + const allowChildren = !requiresAuth || !!user; + const showLockBanner = !!user?.isLocked && !hasInfiniteHearts(user.role) && pathname !== "/lockout"; + + return ( +
+
+ + {showLockBanner && ( +
+ حساب شما قفل شده است — رفع محدودیت فقط با بررسی مدیریت انجام می‌شود. + +
+ )} +
+ {allowChildren ? children : null} +
+
+
+ +

+ بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر +

+
+
+ {user && } +
+ ); +} diff --git a/frontend/src/components/apple-emoji.tsx b/frontend/src/components/apple-emoji.tsx new file mode 100644 index 0000000..1ee3ecb --- /dev/null +++ b/frontend/src/components/apple-emoji.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useMemo, useState, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** + * Apple Color Emoji PNGs served from /emoji/apple/64 (vendored from + * emoji-datasource-apple). External CDNs are blocked in many networks. + */ +const APPLE_BASE = "/emoji/apple/64"; + +/** Convert an emoji grapheme to Apple datasource filename stem (e.g. ❤️ → 2764-fe0f). */ +export function emojiToAppleCode(emoji: string): string | null { + const cps: string[] = []; + for (const ch of emoji) { + const cp = ch.codePointAt(0); + if (cp == null) continue; + // Text-presentation selector only — keep FE0F (required for ❤️ etc.) + if (cp === 0xfe0e) continue; + cps.push(cp.toString(16)); + } + if (cps.length === 0) return null; + return cps.join("-"); +} + +/** Candidate filenames: with FE0F as typed, then without FE0F segments. */ +export function appleEmojiCandidates(emoji: string): string[] { + const primary = emojiToAppleCode(emoji); + if (!primary) return []; + const out = [`${APPLE_BASE}/${primary}.png`]; + if (primary.includes("fe0f")) { + const stripped = primary + .split("-") + .filter((p) => p !== "fe0f") + .join("-"); + if (stripped && stripped !== primary) { + out.push(`${APPLE_BASE}/${stripped}.png`); + } + } else { + // Some keycaps / symbols are stored with -fe0f even when the string lacks it + out.push(`${APPLE_BASE}/${primary}-fe0f.png`); + } + return out; +} + +export function appleEmojiSrc(emoji: string): string | null { + return appleEmojiCandidates(emoji)[0] ?? null; +} + +export function AppleEmoji({ + emoji, + size = 20, + className, + title, +}: { + emoji: string; + size?: number; + className?: string; + title?: string; +}) { + const candidates = useMemo(() => appleEmojiCandidates(emoji), [emoji]); + const [idx, setIdx] = useState(0); + const [failed, setFailed] = useState(false); + const src = candidates[idx]; + + if (!src || failed) { + return ( + + {emoji} + + ); + } + + return ( + // eslint-disable-next-line @next/next/no-img-element + {emoji} { + if (idx + 1 < candidates.length) { + setIdx((i) => i + 1); + } else { + setFailed(true); + } + }} + /> + ); +} + +const EMOJI_RE = + /(?:\p{Extended_Pictographic}(?:\uFE0F)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F)?)*)|\p{Regional_Indicator}{2}/gu; + +/** Render plain text with Apple emoji images inline. */ +export function AppleEmojiText({ + text, + size = 18, + className, +}: { + text: string; + size?: number; + className?: string; +}) { + if (!text) return null; + const parts: ReactNode[] = []; + let last = 0; + let i = 0; + for (const match of text.matchAll(EMOJI_RE)) { + const idx = match.index ?? 0; + if (idx > last) { + parts.push({text.slice(last, idx)}); + } + parts.push(); + last = idx + match[0].length; + } + if (last < text.length) { + parts.push({text.slice(last)}); + } + return {parts}; +} diff --git a/frontend/src/components/brand-mark.tsx b/frontend/src/components/brand-mark.tsx new file mode 100644 index 0000000..71d7d35 --- /dev/null +++ b/frontend/src/components/brand-mark.tsx @@ -0,0 +1,69 @@ +import Image from "next/image"; +import { cn } from "@/lib/utils"; +import logoSrc from "../../public/logo.png"; + +type BrandMarkProps = { + className?: string; + size?: "sm" | "md" | "lg"; + /** Persian brand by default; pass "en" for Latin "Pargar" */ + locale?: "en" | "fa"; + /** Show the Pargar logo mark (default true) */ + showLogo?: boolean; + /** Logo image only, no wordmark */ + logoOnly?: boolean; +}; + +const sizeClass = { + sm: "text-base", + md: "text-lg", + lg: "text-3xl sm:text-4xl", +}; + +const logoPx = { + sm: 28, + md: 36, + lg: 56, +}; + +/** پرگار / Pargar */ +export function BrandMark({ + className, + size = "md", + locale = "fa", + showLogo = true, + logoOnly = false, +}: BrandMarkProps) { + const px = logoPx[size]; + const label = locale === "en" ? "Pargar" : "پرگار"; + + const wordmark = + locale === "en" ? ( + + Pargar + + ) : ( + + پرگار + + ); + + return ( + + {showLogo && ( + {logoOnly + )} + {!logoOnly && wordmark} + + ); +} diff --git a/frontend/src/components/empty-state.tsx b/frontend/src/components/empty-state.tsx new file mode 100644 index 0000000..a7d65fa --- /dev/null +++ b/frontend/src/components/empty-state.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export function EmptyState({ + icon, + title, + description, + action, + className, +}: { + icon?: ReactNode; + title: ReactNode; + description?: ReactNode; + action?: ReactNode; + className?: string; +}) { + return ( +
+ {icon && ( + + {icon} + + )} +
+

{title}

+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/error-boundary.tsx b/frontend/src/components/error-boundary.tsx new file mode 100644 index 0000000..e0371da --- /dev/null +++ b/frontend/src/components/error-boundary.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { Button } from "@/components/ui/button"; + +type Props = { + children: ReactNode; + /** Optional label for support / logs */ + area?: string; +}; + +type State = { + error: Error | null; +}; + +/** + * Catches render/runtime errors in the subtree and shows a recoverable Persian UI + * instead of a blank screen. + */ +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // Keep console for operators; avoid leaking stack into the UI. + console.error(`[ErrorBoundary${this.props.area ? `:${this.props.area}` : ""}]`, error, info.componentStack); + } + + private reset = () => { + this.setState({ error: null }); + }; + + render() { + if (!this.state.error) { + return this.props.children; + } + return ( +
+

مشکلی در نمایش این بخش پیش آمد

+

+ صفحه را یک‌بار تازه کنید. اگر ادامه داشت، کمی بعد دوباره تلاش کنید. +

+
+ + +
+
+ ); + } +} diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx new file mode 100644 index 0000000..dbc0d55 --- /dev/null +++ b/frontend/src/components/navbar.tsx @@ -0,0 +1,222 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { Flame, Heart, LogOut, Sparkles, User, UserCog } from "lucide-react"; +import { NotificationBell } from "@/components/notification-bell"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { BrandMark } from "@/components/brand-mark"; +import { useRealtimeGateway } from "@/hooks/use-realtime"; +import { useLiveUser } from "@/hooks/use-live-user"; +import { useAuth } from "@/lib/auth-store"; +import { http } from "@/lib/api"; +import { heartsOf, hasInfiniteHearts, MAX_HEARTS, INFINITE_HEARTS_LABEL } from "@/lib/hearts"; +import { useState } from "react"; +import { AvatarStudio } from "@/components/avatar-studio"; +import { UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { cn, formatFaNumber } from "@/lib/utils"; + +const navLinks = [ + { href: "/cap", label: "درخت مهارت" }, + { href: "/unwrap", label: "مرکز" }, + { href: "/leaderboard", label: "لیگ‌ها" }, +]; + +export function Navbar() { + const router = useRouter(); + const pathname = usePathname(); + const user = useAuth((s) => s.user); + const liveUser = useLiveUser(); + useRealtimeGateway(); + const [studioOpen, setStudioOpen] = useState(false); + + const locked = !!liveUser?.isLocked && !hasInfiniteHearts(liveUser.role); + const infiniteHearts = hasInfiniteHearts(liveUser?.role); + const effHearts = heartsOf(liveUser); + + const logout = () => { + const { refreshToken, clear: clearAuth } = useAuth.getState(); + void http.post("/api/auth/logout", refreshToken ? { refreshToken } : {}).catch(() => undefined); + clearAuth(); + router.replace("/"); + }; + + // Guest chrome: home + login only + if (!user) { + return ( +
+
+ + + +
+ + {pathname !== "/login" && ( + + )} +
+
+
+ ); + } + + return ( +
+
+
+ + + + {!locked && ( + + )} +
+ +
+ {liveUser && ( + <> +
+ + + {formatFaNumber(liveUser.streakCurrent)} + + + + {formatFaNumber(liveUser.xp)} + + + + {infiniteHearts ? ( + {INFINITE_HEARTS_LABEL} + ) : ( + <> + {formatFaNumber(effHearts)}/{formatFaNumber(MAX_HEARTS)} + + )} + +
+ + + + + + + +
+ +
+

{liveUser.name}

+

{liveUser.email}

+

{roleLabel(liveUser.role)}

+
+
+ router.push("/profile")}> + پروفایل من + + setStudioOpen(true)}> + ویرایش آواتار + + {liveUser.role === "admin" && ( + router.push("/admin")}> + پنل مدیریت + + )} + {liveUser.role === "mentor" && ( + router.push("/admin")}> + پنل منتور + + )} + + خروج + +
+
+ + + + ویرایش آواتار + + setStudioOpen(false)} /> + + + + )} +
+
+
+ ); +} + +function roleLabel(role: string): string { + if (role === "admin") return "ادمین"; + if (role === "mentor") return "منتور"; + return "هنرجو"; +} diff --git a/frontend/src/components/page-header.tsx b/frontend/src/components/page-header.tsx new file mode 100644 index 0000000..f7729e4 --- /dev/null +++ b/frontend/src/components/page-header.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { Reveal } from "@/components/reveal"; + +export function PageHeader({ + kicker, + title, + description, + actions, + align = "start", + className, +}: { + kicker?: string; + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; + align?: "start" | "center"; + className?: string; +}) { + return ( + +
+ {kicker && ( +

{kicker}

+ )} +

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/page-transition.tsx b/frontend/src/components/page-transition.tsx new file mode 100644 index 0000000..bd1d994 --- /dev/null +++ b/frontend/src/components/page-transition.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { motion } from "framer-motion"; +import { usePathname } from "next/navigation"; +import { useRef } from "react"; +import { softPage } from "@/lib/motion"; + +export function PageTransition({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const first = useRef(true); + + if (first.current) { + first.current = false; + return <>{children}; + } + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/panel.tsx b/frontend/src/components/panel.tsx new file mode 100644 index 0000000..e848067 --- /dev/null +++ b/frontend/src/components/panel.tsx @@ -0,0 +1,55 @@ +"use client"; + +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { Reveal } from "@/components/reveal"; + +export function Panel({ + title, + description, + icon, + tint = "bg-primary/10 text-primary", + actions, + children, + className, + bodyClassName, + delay = 0, +}: { + title?: ReactNode; + description?: ReactNode; + icon?: ReactNode; + tint?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; + bodyClassName?: string; + delay?: number; +}) { + return ( + +
+ {(title || actions) && ( +
+ {icon && ( + + {icon} + + )} +
+ {title &&

{title}

} + {description && ( +

{description}

+ )} +
+ {actions && ( +
+ {actions} +
+ )} +
+ )} +
{children}
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/providers.tsx b/frontend/src/components/providers.tsx new file mode 100644 index 0000000..bc52115 --- /dev/null +++ b/frontend/src/components/providers.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import { MotionConfig } from "framer-motion"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "next-themes"; +import { Toaster, toast } from "sonner"; +import { ErrorBoundary } from "@/components/error-boundary"; +import { queryClient } from "@/lib/query-client"; +import { softTween } from "@/lib/motion"; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + + ); +} + +export { toast }; diff --git a/frontend/src/components/reveal.tsx b/frontend/src/components/reveal.tsx new file mode 100644 index 0000000..f6d33c5 --- /dev/null +++ b/frontend/src/components/reveal.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { motion } from "framer-motion"; +import type { ReactNode } from "react"; +import { softReveal } from "@/lib/motion"; + +export function Reveal({ + children, + delay = 0, + y = 14, + className, +}: { + children: ReactNode; + delay?: number; + y?: number; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/theme-toggle.tsx b/frontend/src/components/theme-toggle.tsx new file mode 100644 index 0000000..c726bd1 --- /dev/null +++ b/frontend/src/components/theme-toggle.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const dark = resolvedTheme === "dark"; + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..694ef68 --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,42 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = "Avatar"; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = "AvatarImage"; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = "AvatarFallback"; + +export { Avatar, AvatarImage, AvatarFallback }; \ No newline at end of file diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..8cec585 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-semibold transition-colors", + { + variants: { + variant: { + default: "border-transparent bg-primary/15 text-primary", + secondary: "border-transparent bg-muted text-muted-foreground", + success: "border-transparent bg-success/15 text-success", + destructive: "border-transparent bg-destructive/15 text-destructive", + gold: "border-transparent bg-gold/15 text-gold", + accent: "border-transparent bg-accent/15 text-accent", + cyan: "border-transparent bg-secondary/15 text-secondary", + outline: "border-border text-foreground", + }, + }, + defaultVariants: { variant: "default" }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; \ No newline at end of file diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..efe0f41 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-sans text-sm font-bold transition-all duration-300 ease-[var(--ease-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer select-none active:translate-y-px active:shadow-none", + { + variants: { + variant: { + default: "border-primary bg-primary text-primary-foreground shadow-offset hover:-translate-y-0.5", + gradient: "border-primary bg-primary text-primary-foreground shadow-offset hover:-translate-y-0.5", + secondary: "border-secondary bg-secondary text-secondary-foreground hover:bg-secondary/90", + outline: "border-2 border-input bg-card text-foreground shadow-offset-sm hover:bg-muted", + ghost: "border-transparent hover:bg-muted text-foreground", + destructive: "border-destructive bg-destructive text-destructive-foreground shadow-offset-sm hover:-translate-y-0.5", + success: "border-success bg-success text-success-foreground shadow-offset-sm hover:-translate-y-0.5", + link: "border-transparent text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 rounded-lg px-4 py-2", + sm: "h-8 rounded-lg px-3 text-xs", + lg: "h-[3.25rem] rounded-2xl px-7 text-base", + icon: "h-10 w-10 rounded-xl", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ; + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; \ No newline at end of file diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..d60d1de --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; \ No newline at end of file diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..9780f34 --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,97 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = "DialogOverlay"; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + بستن + + + +)); +DialogContent.displayName = "DialogContent"; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = "DialogTitle"; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = "DialogDescription"; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; \ No newline at end of file diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..da6d2f3 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 8, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = "DropdownMenuContent"; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = "DropdownMenuItem"; + +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem }; \ No newline at end of file diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..399e65d --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const LATIN_INPUT_TYPES = new Set(["password", "email", "url", "tel"]); + +function wantsLatinDigits(props: React.InputHTMLAttributes): boolean { + const type = props.type ?? "text"; + if (LATIN_INPUT_TYPES.has(type)) return true; + if (props.dir === "ltr") return true; + const ac = (props.autoComplete ?? "").toLowerCase(); + if ( + ac === "username" || + ac === "email" || + ac.includes("password") || + ac === "one-time-code" || + ac === "tel" || + ac === "url" + ) { + return true; + } + const id = `${props.id ?? ""} ${props.name ?? ""}`.toLowerCase(); + if (id.includes("captcha") || id.includes("password") || id.includes("username") || id.includes("email")) { + return true; + } + return false; +} + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + const latin = wantsLatinDigits({ type, ...props }); + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/frontend/src/components/ui/jalali-datetime-picker.tsx b/frontend/src/components/ui/jalali-datetime-picker.tsx new file mode 100644 index 0000000..f9778b5 --- /dev/null +++ b/frontend/src/components/ui/jalali-datetime-picker.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useMemo } from "react"; +import DatePicker, { DateObject } from "react-multi-date-picker"; +import TimePicker from "react-multi-date-picker/plugins/time_picker"; +import persian from "react-date-object/calendars/persian"; +import persian_fa from "react-date-object/locales/persian_fa"; +import { cn } from "@/lib/utils"; + +type Props = { + value?: string; + onChange: (iso: string) => void; + disabled?: boolean; + className?: string; + id?: string; + /** Accessible label for the input */ + "aria-label"?: string; +}; + +function toDateObject(iso: string | undefined): DateObject | undefined { + if (!iso) return undefined; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return undefined; + return new DateObject({ date: d, calendar: persian, locale: persian_fa }); +} + +function toIso(obj: DateObject | null | undefined): string { + if (!obj) return ""; + const js = obj.toDate(); + if (Number.isNaN(js.getTime())) return ""; + return js.toISOString(); +} + +/** Jalali calendar + time; value/onChange use ISO UTC strings for the API. */ +export function JalaliDateTimePicker({ value, onChange, disabled, className, id, "aria-label": ariaLabel }: Props) { + const selected = useMemo(() => toDateObject(value), [value]); + + return ( + { + if (Array.isArray(date)) { + onChange(toIso(date[0] ?? null)); + return; + } + onChange(toIso(date)); + }} + calendar={persian} + locale={persian_fa} + format="YYYY/MM/DD HH:mm" + plugins={[]} + calendarPosition="bottom-right" + disabled={disabled} + editable={false} + inputClass={cn( + "box-border h-11 w-full rounded-lg border border-input bg-card px-3.5 py-0 font-sans text-sm font-medium leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + className + )} + containerClassName="w-full" + aria-label={ariaLabel} + /> + ); +} + +/** True when both ISO strings parse and end >= start. */ +export function isValidIsoRange(startsAt: string, endsAt: string): boolean { + if (!startsAt || !endsAt) return false; + const a = new Date(startsAt).getTime(); + const b = new Date(endsAt).getTime(); + if (Number.isNaN(a) || Number.isNaN(b)) return false; + return b >= a; +} diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..0b30963 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@/lib/utils"; + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = "Label"; + +export { Label }; \ No newline at end of file diff --git a/frontend/src/components/ui/password-input.tsx b/frontend/src/components/ui/password-input.tsx new file mode 100644 index 0000000..8937177 --- /dev/null +++ b/frontend/src/components/ui/password-input.tsx @@ -0,0 +1,46 @@ +"use client"; + +import * as React from "react"; +import { Eye, EyeOff } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +const PasswordInput = React.forwardRef< + HTMLInputElement, + Omit, "type"> +>(({ className, dir, autoComplete, ...props }, ref) => { + const [visible, setVisible] = React.useState(false); + // Default LTR for real passwords; callers can pass dir="rtl" for Persian secrets (e.g. security answer). + const resolvedDir = dir ?? "ltr"; + const isRtl = resolvedDir === "rtl"; + + return ( +
+ + +
+ ); +}); +PasswordInput.displayName = "PasswordInput"; + +export { PasswordInput }; diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..af93506 --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,38 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; + +interface ProgressProps extends React.HTMLAttributes { + value?: number; + gradient?: boolean; + /** Softer fill motion for verified-watch / long jumps. */ + smooth?: boolean; +} + +const Progress = React.forwardRef( + ({ className, value = 0, gradient, smooth, ...props }, ref) => { + const pct = Math.min(100, Math.max(0, value)); + return ( +
+
+
+ ); + } +); +Progress.displayName = "Progress"; + +export { Progress }; diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..0332b7f --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils"; + +export function Skeleton({ className }: { className?: string }) { + return
; +} + +export function SkeletonText({ className }: { className?: string }) { + return ; +} + +export function SkeletonAvatar({ className }: { className?: string }) { + return ; +} \ No newline at end of file diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..2e35f96 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { cn } from "@/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = "Switch"; + +export { Switch }; \ No newline at end of file diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..c73d026 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,51 @@ +"use client"; + +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = "TabsList"; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = "TabsTrigger"; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = "TabsContent"; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; \ No newline at end of file diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000..e4adc55 --- /dev/null +++ b/frontend/src/components/ui/textarea.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.TextareaHTMLAttributes +>(({ className, ...props }, ref) => ( +