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
+
{
+ 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 && 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 (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+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) => (
+
+));
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/frontend/src/components/ui/user-avatar.tsx b/frontend/src/components/ui/user-avatar.tsx
new file mode 100644
index 0000000..6d79a8a
--- /dev/null
+++ b/frontend/src/components/ui/user-avatar.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import BoringAvatar from "boring-avatars";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { cn } from "@/lib/utils";
+
+const AVATAR_COLORS = ["#7d6df2", "#0f9b8e", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#65a30d", "#f59e0b"];
+export { AVATAR_COLORS };
+
+export type AvatarVariantName = "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
+
+export const AVATAR_VARIANTS: { name: AvatarVariantName; label: string }[] = [
+ { name: "marble", label: "مرمر" },
+ { name: "beam", label: "بیم" },
+ { name: "pixel", label: "پیکسلی" },
+ { name: "sunset", label: "غروب" },
+ { name: "ring", label: "حلقه" },
+ { name: "bauhaus", label: "باهاوس" },
+];
+
+export function avatarPaletteOf(raw?: string): string[] {
+ if (!raw || !raw.startsWith("[")) return AVATAR_COLORS;
+ try {
+ const v = JSON.parse(raw);
+ if (Array.isArray(v) && v.length >= 2 && v.every((c) => typeof c === "string")) return v.slice(0, 6);
+ } catch {}
+ return AVATAR_COLORS;
+}
+
+type AvatarOwner = {
+ avatarVariant?: AvatarVariantName;
+ avatarPalette?: string;
+ avatarPhoto?: string;
+} | null | undefined;
+
+export function avatarPropsOf(u: AvatarOwner): { src: string | null; variant: AvatarVariantName; palette: string[] } {
+ return {
+ src: u?.avatarPhoto ?? null,
+ variant: u?.avatarVariant ?? "beam",
+ palette: avatarPaletteOf(u?.avatarPalette),
+ };
+}
+
+export function UserAvatar({
+ name,
+ src,
+ variant = "beam",
+ palette,
+ className,
+}: {
+ name: string;
+ src?: string | null;
+ variant?: AvatarVariantName;
+ palette?: string[];
+ className?: string;
+}) {
+ if (src) {
+ return (
+
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/use-live-user.ts b/frontend/src/hooks/use-live-user.ts
new file mode 100644
index 0000000..9fab500
--- /dev/null
+++ b/frontend/src/hooks/use-live-user.ts
@@ -0,0 +1,44 @@
+"use client";
+
+import { useEffect } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useAuth } from "@/lib/auth-store";
+import { http } from "@/lib/api";
+import type { User } from "@/lib/types";
+
+export function useLiveUser(): User | null {
+ const user = useAuth((s) => s.user);
+ const setUser = useAuth((s) => s.setUser);
+
+ const { data } = useQuery({
+ queryKey: ["me"],
+ queryFn: () => http.get<{ user: User }>("/api/auth/me"),
+ enabled: !!user,
+ refetchInterval: user ? 60_000 : false,
+ });
+
+ useEffect(() => {
+ const fresh = data?.user;
+ if (!fresh || !user) return;
+ if (
+ fresh.id === user.id &&
+ fresh.name === user.name &&
+ fresh.email === user.email &&
+ fresh.role === user.role &&
+ fresh.xp === user.xp &&
+ fresh.hearts === user.hearts &&
+ fresh.streakCurrent === user.streakCurrent &&
+ fresh.streakLongest === user.streakLongest &&
+ fresh.isLocked === user.isLocked &&
+ fresh.isActive === user.isActive &&
+ fresh.avatarVariant === user.avatarVariant &&
+ fresh.avatarPalette === user.avatarPalette &&
+ fresh.avatarPhoto === user.avatarPhoto
+ ) {
+ return;
+ }
+ setUser(fresh);
+ }, [data, user, setUser]);
+
+ return data?.user ?? user;
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/use-realtime.ts b/frontend/src/hooks/use-realtime.ts
new file mode 100644
index 0000000..8eb5d1c
--- /dev/null
+++ b/frontend/src/hooks/use-realtime.ts
@@ -0,0 +1,82 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { useAuth } from "@/lib/auth-store";
+import { getWsUrl } from "@/lib/api";
+import { toast } from "@/components/providers";
+
+interface WSPayload {
+ type?: string;
+ item?: {
+ type?: string;
+ title?: string;
+ body?: string;
+ category?: string;
+ route?: string;
+ };
+ from?: number;
+ [key: string]: unknown;
+}
+
+/** Connects to the realtime gateway, toasts live notifications and bumps unread counts. */
+export function useRealtimeGateway() {
+ const accessToken = useAuth((s) => s.accessToken);
+ const user = useAuth((s) => s.user);
+ const client = useQueryClient();
+ const lastEvent = useRef>({});
+
+ useEffect(() => {
+ if (!user) return;
+ let ws: WebSocket | null = null;
+ let retry = 0;
+ let closed = false;
+
+ const connect = () => {
+ if (closed) return;
+ try {
+ // Prefer subprotocol when we have an in-memory access token; otherwise rely on httpOnly cookie.
+ ws = accessToken
+ ? new WebSocket(getWsUrl(), ["bearer", accessToken])
+ : new WebSocket(getWsUrl());
+ } catch {
+ return;
+ }
+ ws.onopen = () => {
+ retry = 0;
+ };
+ ws.onmessage = (ev) => {
+ try {
+ const p: WSPayload = JSON.parse(ev.data);
+ if (p.type === "notification" && p.item) {
+ const n = p.item;
+ const stamp = Date.now();
+ const dedupeKey = `${n.type}:${n.title}:${n.body}`;
+ if (stamp - (lastEvent.current[dedupeKey] ?? 0) < 2000) return;
+ lastEvent.current[dedupeKey] = stamp;
+
+ toast.info(`${n.title}${n.body ? ` — ${n.body}` : ""}`, { duration: 5000 });
+ client.invalidateQueries({ queryKey: ["notifications"] });
+ client.invalidateQueries({ queryKey: ["me"] });
+ }
+ } catch {
+ /* ignore malformed frames */
+ }
+ };
+ ws.onclose = () => {
+ if (closed) return;
+ retry += 1;
+ const t = Math.min(1000 * 2 ** retry, 15000);
+ setTimeout(connect, t);
+ };
+ };
+
+ connect();
+ return () => {
+ closed = true;
+ ws?.close();
+ };
+ }, [accessToken, user, client]);
+
+ return null;
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..25eaf19
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,270 @@
+"use client";
+
+import { toast } from "sonner";
+import { useAuth } from "./auth-store";
+
+/** Empty string = same-origin (production behind reverse proxy). */
+function resolveApiBase(): string {
+ const fromEnv = process.env.NEXT_PUBLIC_API_URL;
+ if (fromEnv !== undefined && fromEnv !== "") {
+ return fromEnv.replace(/\/$/, "");
+ }
+ return "";
+}
+
+const API = resolveApiBase();
+
+const RATE_LIMIT_TOAST_ID = "api-rate-limit";
+const RATE_LIMIT_FALLBACK = "تعداد درخواستها زیاد است؛ لطفاً کمی صبر کنید";
+
+export type ApiErrorBody = {
+ error?: string;
+ code?: string;
+ requestId?: string;
+};
+
+export class ApiError extends Error {
+ status: number;
+ code?: string;
+ requestId?: string;
+
+ constructor(message: string, status: number, opts?: { code?: string; requestId?: string }) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ this.code = opts?.code;
+ this.requestId = opts?.requestId;
+ }
+}
+
+function newClientRequestId(): string {
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
+ }
+ return Math.random().toString(16).slice(2, 18);
+}
+
+/** Show/update a single rate-limit toast — never logs the user out. */
+export function notifyRateLimit(message?: string): void {
+ const msg = message?.trim() || RATE_LIMIT_FALLBACK;
+ toast.error(msg, { id: RATE_LIMIT_TOAST_ID, duration: 6_000 });
+}
+
+async function readErrorBody(res: Response): Promise {
+ const requestId = res.headers.get("X-Request-ID") ?? undefined;
+ try {
+ const body = (await res.json()) as ApiErrorBody;
+ return {
+ error: body?.error ? String(body.error) : undefined,
+ code: body?.code ? String(body.code) : undefined,
+ requestId: body?.requestId ? String(body.requestId) : requestId,
+ };
+ } catch {
+ return { requestId };
+ }
+}
+
+function throwHttpError(status: number, body: ApiErrorBody, fallback: string): never {
+ const message = body.error?.trim() || fallback;
+ if (status === 429) {
+ notifyRateLimit(message);
+ }
+ throw new ApiError(message, status, { code: body.code, requestId: body.requestId });
+}
+
+/** Turn a failed fetch Response into ApiError (for raw fetch call sites). */
+export async function throwFromResponse(res: Response, fallback?: string): Promise {
+ const body = await readErrorBody(res);
+ throwHttpError(res.status, body, fallback ?? `Request failed (${res.status})`);
+}
+
+/** Map any thrown value to a concise Persian user-facing message. */
+export function toUserError(err: unknown, fallback = "خطایی رخ داد؛ دوباره تلاش کنید"): string {
+ if (err instanceof ApiError) {
+ const msg = err.message?.trim();
+ switch (err.status) {
+ case 401: {
+ if (msg && msg !== "session expired" && msg !== "no session") {
+ return msg;
+ }
+ return "نشست منقضی شده؛ دوباره وارد شوید.";
+ }
+ case 403:
+ return msg || "دسترسی کافی ندارید.";
+ case 404:
+ return msg || "مورد درخواستی پیدا نشد.";
+ case 409:
+ return msg || "این عملیات با وضعیت فعلی در تعارض است.";
+ case 413:
+ return msg || "حجم فایل بیش از حد مجاز است.";
+ case 423:
+ return msg || "حساب شما محدود شده است.";
+ case 429:
+ return msg || RATE_LIMIT_FALLBACK;
+ default:
+ break;
+ }
+ if (err.status >= 500) {
+ const base = msg || "مشکل موقتی سرور؛ کمی بعد دوباره تلاش کنید.";
+ return err.requestId ? `${base} (کد پیگیری: ${err.requestId})` : base;
+ }
+ return msg || fallback;
+ }
+ if (err instanceof TypeError) {
+ return "ارتباط با سرور برقرار نشد. اتصال اینترنت را بررسی کنید.";
+ }
+ if (err instanceof Error) {
+ const msg = err.message.trim();
+ if (!msg) return fallback;
+ if (msg === "session expired" || msg === "no session") {
+ return "نشست منقضی شده؛ دوباره وارد شوید.";
+ }
+ if (msg === "Failed to fetch" || msg.includes("NetworkError") || msg.includes("Load failed")) {
+ return "ارتباط با سرور برقرار نشد. اتصال اینترنت را بررسی کنید.";
+ }
+ return msg;
+ }
+ return fallback;
+}
+
+/** Toast a background/query failure without spamming auth/not-found noise. */
+export function notifyQueryError(err: unknown): void {
+ if (err instanceof ApiError) {
+ if (err.status === 401 || err.status === 403 || err.status === 404) return;
+ if (err.status === 429) return; // already toasted
+ const id = err.requestId
+ ? `query-err-${err.requestId}`
+ : `query-err-${err.status}-${err.code ?? "x"}`;
+ toast.error(toUserError(err), { id, duration: 6_000 });
+ return;
+ }
+ toast.error(toUserError(err), { id: "query-network", duration: 6_000 });
+}
+
+let refreshInFlight: Promise | null = null;
+
+export async function refreshSession(): Promise {
+ if (refreshInFlight) {
+ return refreshInFlight;
+ }
+ refreshInFlight = (async () => {
+ const { refreshToken, setSession, user } = useAuth.getState();
+ // Cookie session may refresh without an in-memory refresh token.
+ if (!refreshToken && !user) {
+ useAuth.getState().clear();
+ throw new ApiError("no session", 401, { code: "unauthorized" });
+ }
+ const requestId = newClientRequestId();
+ const res = await fetch(`${API}/api/auth/refresh`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Request-ID": requestId,
+ },
+ body: JSON.stringify(refreshToken ? { refreshToken } : {}),
+ });
+ if (!res.ok) {
+ const body = await readErrorBody(res);
+ if (res.status === 429) {
+ throwHttpError(429, body, RATE_LIMIT_FALLBACK);
+ }
+ useAuth.getState().clear();
+ throw new ApiError("session expired", 401, {
+ code: body.code ?? "unauthorized",
+ requestId: body.requestId ?? requestId,
+ });
+ }
+ const data = await res.json();
+ setSession(data.accessToken, data.refreshToken, data.user);
+ })().finally(() => {
+ refreshInFlight = null;
+ });
+ return refreshInFlight;
+}
+
+function authHeaders(token: string | null, requestId: string, json: boolean): HeadersInit {
+ return {
+ "X-Request-ID": requestId,
+ ...(json ? { "Content-Type": "application/json" } : {}),
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+}
+
+/** Authenticated JSON fetch with silent token refresh. */
+export async function api(path: string, opts: { method?: string; body?: unknown } = {}): Promise {
+ const { accessToken } = useAuth.getState();
+ const requestId = newClientRequestId();
+ const doFetch = (token: string | null) =>
+ fetch(`${API}${path}`, {
+ method: opts.method ?? "GET",
+ credentials: "include",
+ headers: authHeaders(token, requestId, true),
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
+ });
+
+ let res = await doFetch(accessToken);
+ if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
+ await refreshSession();
+ res = await doFetch(useAuth.getState().accessToken);
+ }
+ if (!res.ok) {
+ const body = await readErrorBody(res);
+ throwHttpError(res.status, body, `Request failed (${res.status})`);
+ }
+ if (res.status === 204) return undefined as T;
+ return (await res.json()) as T;
+}
+
+export const API_URL = API;
+
+/** WebSocket URL: env override, else same-host /api/ws (ws/wss from page protocol). */
+export function getWsUrl(): string {
+ const fromEnv = process.env.NEXT_PUBLIC_WS_URL;
+ if (fromEnv) {
+ return fromEnv;
+ }
+ if (typeof window === "undefined") {
+ return "ws://localhost:8080/api/ws";
+ }
+ const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
+ return `${proto}//${window.location.host}/api/ws`;
+}
+
+// ---- endpoint helpers (typed) ----
+export const http = {
+ get: (path: string) => api(path),
+ post: (path: string, body?: unknown) => api(path, { method: "POST", body }),
+ put: (path: string, body?: unknown) => api(path, { method: "PUT", body }),
+ del: (path: string) => api(path, { method: "DELETE" }),
+};
+
+/** Authenticated multipart upload with silent token refresh. */
+export async function apiForm(path: string, form: FormData): Promise {
+ const { accessToken } = useAuth.getState();
+ const requestId = newClientRequestId();
+ const doFetch = (token: string | null) =>
+ fetch(`${API}${path}`, {
+ method: "POST",
+ credentials: "include",
+ headers: authHeaders(token, requestId, false),
+ body: form,
+ });
+
+ let res = await doFetch(accessToken);
+ if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
+ await refreshSession();
+ res = await doFetch(useAuth.getState().accessToken);
+ }
+ if (!res.ok) {
+ const body = await readErrorBody(res);
+ throwHttpError(res.status, body, `Upload failed (${res.status})`);
+ }
+ return (await res.json()) as T;
+}
+
+/** Resolve a signed media URL for a storage key (authenticated). */
+export async function signedMediaUrl(key: string): Promise {
+ const { url } = await http.get<{ url: string }>(`/api/media/sign?key=${encodeURIComponent(key)}`);
+ return url;
+}
diff --git a/frontend/src/lib/auth-store.ts b/frontend/src/lib/auth-store.ts
new file mode 100644
index 0000000..f7851ae
--- /dev/null
+++ b/frontend/src/lib/auth-store.ts
@@ -0,0 +1,63 @@
+"use client";
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { User } from "./types";
+import { queryClient } from "./query-client";
+
+interface AuthState {
+ user: User | null;
+ /** In-memory only — never persisted (httpOnly cookies hold the session). */
+ accessToken: string | null;
+ refreshToken: string | null;
+ _hasHydrated: boolean;
+ setHasHydrated: (v: boolean) => void;
+ setSession: (access: string, refresh: string, user: User) => void;
+ setUser: (user: User) => void;
+ setTokens: (access: string, refresh: string) => void;
+ clear: () => void;
+}
+
+export const useAuth = create()(
+ persist(
+ (set) => ({
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ _hasHydrated: false,
+ setHasHydrated: (v) => set({ _hasHydrated: v }),
+ setSession: (accessToken, refreshToken, user) =>
+ set({ accessToken, refreshToken, user }),
+ setUser: (user) => set({ user }),
+ setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
+ clear: () => {
+ queryClient.clear();
+ set({ user: null, accessToken: null, refreshToken: null });
+ },
+ }),
+ {
+ name: "pargar.auth",
+ // Persist identity only — tokens live in httpOnly cookies + memory.
+ partialize: (s) => ({
+ user: s.user,
+ }),
+ merge: (persisted, current) => {
+ const p = (persisted ?? {}) as Partial;
+ return {
+ ...current,
+ ...p,
+ // Drop any legacy tokens that may still be in older localStorage blobs.
+ accessToken: null,
+ refreshToken: null,
+ };
+ },
+ onRehydrateStorage: () => (state) => {
+ state?.setHasHydrated(true);
+ },
+ }
+ )
+);
+
+export function useAuthHydrated() {
+ return useAuth((s) => s._hasHydrated);
+}
diff --git a/frontend/src/lib/hearts.ts b/frontend/src/lib/hearts.ts
new file mode 100644
index 0000000..5f921c3
--- /dev/null
+++ b/frontend/src/lib/hearts.ts
@@ -0,0 +1,32 @@
+"use client";
+
+import type { Role, User } from "./types";
+
+/** Hard cap for students — no timed refill. */
+export const MAX_HEARTS = 3;
+
+/** Infinity glyph for staff heart display. */
+export const INFINITE_HEARTS_LABEL = "∞";
+
+export function hasInfiniteHearts(role: Role | string | null | undefined): boolean {
+ return role === "admin" || role === "mentor";
+}
+
+export function clampHearts(hearts: number | null | undefined): number {
+ if (hearts == null || Number.isNaN(hearts)) {
+ return MAX_HEARTS;
+ }
+ return Math.max(0, Math.min(MAX_HEARTS, hearts));
+}
+
+/** Effective hearts from the server user — never invents a timed refill. */
+export function heartsOf(user: User | null | undefined): number {
+ return clampHearts(user?.hearts);
+}
+
+export function formatHeartsDisplay(user: User | null | undefined): string {
+ if (hasInfiniteHearts(user?.role)) {
+ return INFINITE_HEARTS_LABEL;
+ }
+ return String(clampHearts(user?.hearts));
+}
diff --git a/frontend/src/lib/motion.ts b/frontend/src/lib/motion.ts
new file mode 100644
index 0000000..8a9495b
--- /dev/null
+++ b/frontend/src/lib/motion.ts
@@ -0,0 +1,37 @@
+/** Soft motion presets — use everywhere Framer Motion runs. */
+export const easeSoft = [0.22, 1, 0.36, 1] as const;
+export const easeSoftInOut = [0.45, 0.05, 0.25, 1] as const;
+
+/** Gentle settle — panels, sheets, floating buttons */
+export const softSpring = {
+ type: "spring" as const,
+ stiffness: 180,
+ damping: 26,
+ mass: 0.9,
+};
+
+/** Softer pop — chips, hearts, quick reactions */
+export const softPop = {
+ type: "spring" as const,
+ stiffness: 220,
+ damping: 22,
+ mass: 0.85,
+};
+
+/** Short fade/slide without bounce */
+export const softTween = {
+ duration: 0.38,
+ ease: easeSoft,
+};
+
+/** Page / route change */
+export const softPage = {
+ duration: 0.42,
+ ease: easeSoft,
+};
+
+/** Reveal-on-scroll */
+export const softReveal = {
+ duration: 0.75,
+ ease: easeSoft,
+};
diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts
new file mode 100644
index 0000000..35eed35
--- /dev/null
+++ b/frontend/src/lib/query-client.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { QueryCache, QueryClient, keepPreviousData } from "@tanstack/react-query";
+import { notifyQueryError } from "@/lib/api";
+
+function statusOf(error: unknown): number | undefined {
+ if (error && typeof error === "object" && "status" in error) {
+ const status = (error as { status: unknown }).status;
+ return typeof status === "number" ? status : undefined;
+ }
+ return undefined;
+}
+
+function shouldRetry(failureCount: number, error: unknown): boolean {
+ const status = statusOf(error);
+ // Never hammer rate limits or auth/permission failures.
+ if (status === 429 || status === 401 || status === 403 || status === 404) {
+ return false;
+ }
+ return failureCount < 1;
+}
+
+export const queryClient = new QueryClient({
+ queryCache: new QueryCache({
+ onError: (error, query) => {
+ // Opt out per-query: meta: { silentError: true }
+ if (query.meta && (query.meta as { silentError?: boolean }).silentError) {
+ return;
+ }
+ notifyQueryError(error);
+ },
+ }),
+ defaultOptions: {
+ queries: {
+ retry: shouldRetry,
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ gcTime: 5 * 60_000,
+ placeholderData: keepPreviousData,
+ },
+ mutations: {
+ retry: false,
+ },
+ },
+});
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..a88fc31
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,408 @@
+export type Role = "student" | "mentor" | "admin";
+
+export interface User {
+ id: number;
+ email: string;
+ username: string;
+ name: string;
+ role: Role;
+ xp: number;
+ hearts: number;
+ heartsUpdatedAt: string;
+ streakCurrent: number;
+ streakLongest: number;
+ isLocked: boolean;
+ lockedAt?: string;
+ isActive: boolean;
+ avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
+ avatarPalette?: string;
+ avatarPhoto?: string;
+ phone?: string;
+ securityQuestion?: string;
+ hasSecurityAnswer?: boolean;
+ createdAt: string;
+}
+
+export interface Certificate {
+ id: number;
+ userId: number;
+ publicId: string;
+ fullName: string;
+ issuedAt: string;
+ revokedAt?: string;
+}
+
+export type PhysicalOrderStatus = "requested" | "paid" | "shipped" | "cancelled";
+
+export interface CertificatePhysicalOrder {
+ id: number;
+ certificateId: number;
+ userId: number;
+ status: PhysicalOrderStatus;
+ note: string;
+ recipientName: string;
+ phone: string;
+ address: string;
+ city: string;
+ postalCode: string;
+ trackingCode: string;
+ windowEndsAt: string;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string;
+ publicId?: string;
+}
+
+export interface PhysicalCertSettings {
+ enabled: boolean;
+ priceIrr: number;
+ windowDays: number;
+}
+
+export interface LessonProgress {
+ userId: number;
+ lessonId: number;
+ watchedSeconds: number;
+ watchedPct: number;
+ lastPosition: number;
+ quizUnlocked: boolean;
+ passedQuiz: boolean;
+ quizCompletedAt?: string;
+}
+
+export interface Lesson {
+ id: number;
+ chapterId: number;
+ title: string;
+ description: string;
+ videoKey: string;
+ durationSeconds: number;
+ completionThresholdPct: number;
+ requiresLessonId?: number;
+ x: number;
+ y: number;
+ sortOrder: number;
+ xpReward: number;
+ isActive: boolean;
+}
+
+export interface Chapter {
+ id: number;
+ title: string;
+ description: string;
+ icon: string;
+ sortOrder: number;
+}
+
+export interface TreeLesson {
+ id: number;
+ title: string;
+ description: string;
+ progress?: LessonProgress;
+ quizStatus: "" | "in_progress" | "passed" | "failed";
+ locked: boolean;
+ requiresLessonId?: number;
+ x: number;
+ y: number;
+ xpReward: number;
+ durationSeconds: number;
+ chapterId: number;
+}
+
+export interface TreeChapter extends Chapter {
+ lessons: TreeLesson[];
+}
+
+export interface TreeData {
+ chapters: TreeChapter[];
+ me: { xp: number; streak: number; hearts: number; isLocked: boolean; role: Role };
+}
+
+export interface QuizQuestion {
+ id: number;
+ position: number;
+ question: string;
+ options: string[];
+}
+
+export interface QuizData {
+ lessonID: number;
+ duration: number;
+ questions: QuizQuestion[];
+ passed: boolean;
+ quizStatus: string;
+ lastAttempt?: {
+ id: number;
+ correctCount: number;
+ total: number;
+ status: string;
+ createdAt: string;
+ } | null;
+}
+
+export interface QuizResult {
+ passed: boolean;
+ correctCount: number;
+ total: number;
+ heartsLost: number;
+ heartsLeft: number;
+ xpEarned: number;
+ locked: boolean;
+ wasLocked: boolean;
+ streak: number;
+}
+
+export interface MeetingEvent {
+ id: number;
+ title: string;
+ description: string;
+ eventType: "meet" | "zoom" | "workshop";
+ externalUrl: string;
+ startsAt: string;
+ endsAt: string;
+ rsvped?: boolean;
+}
+
+export interface NotificationItem {
+ id: number;
+ category: string;
+ type: string;
+ title: string;
+ body: string;
+ route: string;
+ data: Record;
+ readAt?: string;
+ createdAt: string;
+}
+
+export interface ChatAttachment {
+ type: "image" | "video" | "audio" | "file";
+ url: string;
+ name: string;
+ size: number;
+}
+
+export interface ChatMessage {
+ id: number;
+ userId: number;
+ mentorId: number;
+ senderRole: Role;
+ body: string;
+ readAt?: string;
+ createdAt: string;
+ replyTo?: number;
+ pinnedAt?: string;
+ pinned?: boolean;
+ attachment?: ChatAttachment;
+ reactions?: ChatReaction[];
+}
+
+export interface ChatReaction {
+ emoji: string;
+ count: number;
+ mine: boolean;
+}
+
+export interface Mentor {
+ id: number;
+ name: string;
+ email: string;
+ role: Role;
+ online?: boolean;
+ avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
+ avatarPalette?: string;
+ avatarPhoto?: string;
+}
+
+export interface Conversation {
+ partner: {
+ id: number;
+ name: string;
+ email: string;
+ role: Role;
+ online?: boolean;
+ avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
+ avatarPalette?: string;
+ avatarPhoto?: string;
+ };
+ lastMessage?: ChatMessage;
+ unreadCount: number;
+}
+
+export interface LeaderboardEntry {
+ userId: number;
+ name: string;
+ email: string;
+ xp: number;
+ rank: number;
+ avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
+ avatarPalette?: string;
+ avatarPhoto?: string;
+}
+
+export interface LeaderboardData {
+ week: string;
+ entries: LeaderboardEntry[];
+ me: { rank: number; xp: number; streak: number };
+}
+
+export interface NotificationPrefs {
+ userId: number;
+ progress: boolean;
+ gamification: boolean;
+ mentor: boolean;
+ event: boolean;
+}
+
+export interface MCQQuestion {
+ id: number;
+ lessonId: number;
+ position: number;
+ question: string;
+ options: string[];
+ answerIndex: number;
+ explanation: string;
+}
+
+export interface AdminStats {
+ users: number;
+ lessons: number;
+ questions: number;
+ events: number;
+ completions: number;
+}
+
+export interface LearningOverview {
+ studentsActive: number;
+ studentsLocked: number;
+ studentsTotal: number;
+ lessonsTotal: number;
+ completions: number;
+ attempts7d: number;
+ passes7d: number;
+ passRate7d: number;
+ avgWatchPct: number;
+ xpAwarded7d: number;
+ studentsStreaking: number;
+}
+
+export interface StudentLearningRow {
+ id: number;
+ name: string;
+ username: string;
+ email: string;
+ xp: number;
+ hearts: number;
+ streakCurrent: number;
+ isLocked: boolean;
+ isActive: boolean;
+ lessonsPassed: number;
+ lessonsTotal: number;
+ progressPct: number;
+ attempts7d: number;
+ passes7d: number;
+ passRate7d: number;
+ lastActivityAt?: string;
+}
+
+export interface LessonLearningDetail {
+ lessonId: number;
+ chapterId: number;
+ chapterTitle: string;
+ title: string;
+ sortOrder: number;
+ watchedPct: number;
+ quizUnlocked: boolean;
+ passedQuiz: boolean;
+ quizCompletedAt?: string;
+ lastAttemptStatus?: string;
+ lastAttemptScore?: number;
+}
+
+export interface AttemptLearningDetail {
+ id: number;
+ lessonId: number;
+ lessonTitle: string;
+ correctCount: number;
+ total: number;
+ scorePct: number;
+ status: string;
+ heartsLost: number;
+ createdAt: string;
+}
+
+export interface XPEventDetail {
+ id: number;
+ amount: number;
+ source: string;
+ refId: number;
+ createdAt: string;
+}
+
+export interface WatchSessionDetail {
+ lessonId: number;
+ lessonTitle: string;
+ startedAt: string;
+ lastHeartbeatAt: string;
+ lastPosition: number;
+}
+
+export interface UserLearningDetail {
+ user: StudentLearningRow;
+ lessons: LessonLearningDetail[];
+ attempts: AttemptLearningDetail[];
+ xpEvents: XPEventDetail[];
+ sessions: WatchSessionDetail[];
+}
+
+export interface AdminEvent extends MeetingEvent {
+ isActive: boolean;
+ createdAt: string;
+}
+
+export type InviteStatus = "active" | "paused" | "revoked" | "exhausted";
+
+export interface RegistrationInvite {
+ id: number;
+ label: string;
+ maxUses: number;
+ usedCount: number;
+ remaining: number;
+ status: InviteStatus;
+ expiresAt?: string;
+ createdBy?: number;
+ createdAt: string;
+ token?: string;
+ url?: string;
+}
+
+export type WhitelistStatus = "available" | "consumed";
+
+export interface RegistrationPhoneWhitelist {
+ id: number;
+ phone: string;
+ status: WhitelistStatus;
+ consumedAt?: string;
+ consumedUserId?: number;
+ createdBy?: number;
+ createdAt: string;
+}
+
+export interface RegistrationPhoneBlacklist {
+ id: number;
+ phone: string;
+ createdBy?: number;
+ createdAt: string;
+}
+
+export interface RegistrationBlacklistAttempt {
+ id: number;
+ phone: string;
+ path: "public" | "invite" | string;
+ inviteId?: number;
+ ip: string;
+ userAgent?: string;
+ attemptedUsername?: string;
+ attemptedEmail?: string;
+ attemptedAt: string;
+}
\ No newline at end of file
diff --git a/frontend/src/lib/upload.ts b/frontend/src/lib/upload.ts
new file mode 100644
index 0000000..464652d
--- /dev/null
+++ b/frontend/src/lib/upload.ts
@@ -0,0 +1,70 @@
+"use client";
+
+import { apiForm, toUserError } from "@/lib/api";
+import type { User } from "@/lib/types";
+
+const MAX_VIDEO_BYTES = 200 * 1024 * 1024;
+const ALLOWED_MIME = new Set(["video/mp4", "video/webm"]);
+
+/** Client-side gate before hitting the network. Prefers MP4. */
+export function validateVideoFile(file: File): string | null {
+ if (!file || file.size <= 0) {
+ return "فایل خالی است.";
+ }
+ if (file.size > MAX_VIDEO_BYTES) {
+ return "حجم ویدیو باید کمتر از ۲۰۰ مگابایت باشد.";
+ }
+
+ const name = file.name.toLowerCase().trim();
+ const isMp4 = name.endsWith(".mp4");
+ const isWebm = name.endsWith(".webm");
+ if (!isMp4 && !isWebm) {
+ return "فقط فایلهای MP4 یا WebM مجاز است (ترجیحاً MP4).";
+ }
+
+ const mime = (file.type || "").toLowerCase().split(";")[0].trim();
+ if (mime && mime !== "application/octet-stream" && !ALLOWED_MIME.has(mime)) {
+ return "نوع فایل پشتیبانی نمیشود؛ MP4 یا WebM بفرستید.";
+ }
+ if (mime === "video/mp4" && isWebm) {
+ return "پسوند و نوع فایل همخوان نیست.";
+ }
+ if (mime === "video/webm" && isMp4) {
+ return "پسوند و نوع فایل همخوان نیست.";
+ }
+
+ return null;
+}
+
+/** Multipart upload that quietly refreshes the session on 401. */
+export async function uploadVideo(
+ lessonHint: number,
+ file: File
+): Promise<{ url: string; key: string; size: number; mime?: string }> {
+ const invalid = validateVideoFile(file);
+ if (invalid) {
+ throw new Error(invalid);
+ }
+
+ try {
+ const form = new FormData();
+ form.append("file", file);
+ return await apiForm<{ url: string; key: string; size: number; mime?: string }>(
+ `/api/admin/videos?lesson=${lessonHint}`,
+ form
+ );
+ } catch (err) {
+ throw new Error(toUserError(err, "آپلود ویدیو ممکن نشد"));
+ }
+}
+
+export function initialsOf(name: string) {
+ return name
+ .split(" ")
+ .map((p) => p[0])
+ .slice(0, 2)
+ .join("")
+ .toUpperCase();
+}
+
+export type { User };
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..1e7aa0d
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,30 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatWinHWin(v: number, digits = 0) {
+ return new Intl.NumberFormat("fa-IR", { maximumFractionDigits: digits }).format(v);
+}
+
+export function formatFaNumber(value: number, digits = 0) {
+ return new Intl.NumberFormat("fa-IR", { maximumFractionDigits: digits }).format(value);
+}
+
+/** Streak label — never use a hyphen (۰-روزه looks like a negative). */
+export function formatStreakLabel(days: number) {
+ const n = Number.isFinite(days) ? Math.max(0, Math.floor(days)) : 0;
+ return `${formatFaNumber(n)} روز`;
+}
+
+/** Quiz option letters: الف، ب، ج، د، … */
+const FA_OPTION_LETTERS = ["الف", "ب", "ج", "د", "ه", "و"] as const;
+
+export function formatOptionLetter(index: number): string {
+ if (index >= 0 && index < FA_OPTION_LETTERS.length) {
+ return FA_OPTION_LETTERS[index];
+ }
+ return formatFaNumber(index + 1);
+}
\ No newline at end of file
diff --git a/frontend/src/lib/validation.ts b/frontend/src/lib/validation.ts
new file mode 100644
index 0000000..b4832fd
--- /dev/null
+++ b/frontend/src/lib/validation.ts
@@ -0,0 +1,133 @@
+/** Shared field validators — keep messages/rules aligned with backend/internal/api/validate.go */
+
+export const MSG = {
+ nameRequired: "نام الزامی است",
+ nameTooLong: "نام خیلی طولانی است",
+ emailInvalid: "ایمیل نامعتبر است",
+ usernameInvalid:
+ "شناسه باید با حرف انگلیسی شروع شود و ۳ تا ۳۲ نویسهٔ لاتین، عدد، نقطه، خط یا زیرخط باشد",
+ passwordShort: "گذرواژه باید دستکم ۸ نویسه داشته باشد",
+ passwordLong: "گذرواژه بیش از حد طولانی است",
+ passwordMismatch: "گذرواژه و تکرار آن یکسان نیستند",
+ phoneRequired: "شماره تلفن الزامی است",
+ phoneInvalid: "شماره تلفن نامعتبر است (مثال: ۰۹۱۲۱۲۳۴۵۶۷)",
+ secQARequired: "سوال و جواب امنیتی الزامی است",
+ secQShort: "سوال امنیتی خیلی کوتاه است",
+ secQLong: "سوال امنیتی خیلی طولانی است",
+ secAShort: "جواب امنیتی خیلی کوتاه است",
+ secALong: "جواب امنیتی خیلی طولانی است",
+ inviteLabelLong: "برچسب لینک عضویت خیلی طولانی است",
+} as const;
+
+const USERNAME_RE = /^[a-z][a-z0-9._-]{2,31}$/;
+
+/** Max visible length while typing (+989xxxxxxxxx). Canonical form is 11 digits. */
+export const PHONE_INPUT_MAX_LEN = 13;
+
+function mapDigitChar(ch: string): string | null {
+ const code = ch.codePointAt(0)!;
+ if (code >= 0x06f0 && code <= 0x06f9) return String(code - 0x06f0);
+ if (code >= 0x0660 && code <= 0x0669) return String(code - 0x0660);
+ if (ch >= "0" && ch <= "9") return ch;
+ return null;
+}
+
+/** Map Persian/Arabic digits to ASCII and canonicalize Iranian mobiles to 09XXXXXXXXX. */
+export function normalizePhone(raw: string): string {
+ let digits = "";
+ for (const ch of raw.trim()) {
+ const d = mapDigitChar(ch);
+ if (d !== null) digits += d;
+ }
+ if (digits.length === 12 && digits.startsWith("989")) {
+ return `0${digits.slice(2)}`;
+ }
+ if (digits.length === 10 && digits.startsWith("9")) {
+ return `0${digits}`;
+ }
+ return digits;
+}
+
+/**
+ * Limit phone typing to a plausible Iranian mobile length.
+ * Allows 09… (11), 9… (10), 98… / +98… (12 digits) — nothing beyond that.
+ */
+export function clampPhoneInput(raw: string): string {
+ let plus = false;
+ let digits = "";
+ for (const ch of raw) {
+ if (ch === "+" && digits.length === 0 && !plus) {
+ plus = true;
+ continue;
+ }
+ const d = mapDigitChar(ch);
+ if (d === null) continue;
+ const next = digits + d;
+ if (next.startsWith("98")) {
+ if (next.length > 12) continue;
+ } else if (next.startsWith("09") || next.startsWith("0")) {
+ if (next.length > 11) continue;
+ } else if (next.startsWith("9")) {
+ if (next.length > 10) continue;
+ } else if (next.length > 11) {
+ continue;
+ }
+ digits = next;
+ }
+ if (plus && (digits === "" || digits.startsWith("9"))) {
+ return `+${digits}`;
+ }
+ return digits;
+}
+
+export function validateName(v: string): string | null {
+ const name = v.trim();
+ if (!name) return MSG.nameRequired;
+ if ([...name].length > 100) return MSG.nameTooLong;
+ return null;
+}
+
+export function validateUsername(v: string): string | null {
+ const u = v.trim().toLowerCase();
+ if (!u) return "شناسه کاربری الزامی است";
+ if (!USERNAME_RE.test(u)) return MSG.usernameInvalid;
+ return null;
+}
+
+export function validateEmail(v: string): string | null {
+ const e = v.trim().toLowerCase();
+ if (!e) return "ایمیل الزامی است";
+ if (e.length > 200 || /\s/.test(e)) return MSG.emailInvalid;
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) return MSG.emailInvalid;
+ return null;
+}
+
+export function validatePhone(v: string, required = true): string | null {
+ const phone = normalizePhone(v);
+ if (!phone) return required ? MSG.phoneRequired : null;
+ if (!/^09\d{9}$/.test(phone)) return MSG.phoneInvalid;
+ return null;
+}
+
+export function validatePassword(password: string, confirm?: string): string | null {
+ if (password.length < 8) return MSG.passwordShort;
+ if (password.length > 72) return MSG.passwordLong;
+ if (confirm !== undefined && password !== confirm) return MSG.passwordMismatch;
+ return null;
+}
+
+export function validateSecurityQA(question: string, answer: string): string | null {
+ const q = question.trim();
+ const a = answer.trim();
+ if (!q || !a) return MSG.secQARequired;
+ if ([...q].length < 3) return MSG.secQShort;
+ if ([...q].length > 200) return MSG.secQLong;
+ if ([...a].length < 2) return MSG.secAShort;
+ if ([...a].length > 100) return MSG.secALong;
+ return null;
+}
+
+export function validateInviteLabel(label: string): string | null {
+ if ([...label.trim()].length > 80) return MSG.inviteLabelLong;
+ return null;
+}