feat(frontend): add shared client libraries and UI primitives
Introduce API helpers, types, hooks, design-system components, and the authenticated app shell.
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<div aria-hidden className="h-1 w-full bg-gradient-to-l from-primary via-accent to-gold" />
|
||||||
|
<div className="sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur-xl">
|
||||||
|
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||||
|
<Skeleton className="h-3.5 w-28" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-9 w-24 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-56" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-36 rounded-2xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<div aria-hidden className="h-1 w-full bg-gradient-to-l from-primary via-accent to-gold" />
|
||||||
|
<Navbar />
|
||||||
|
{showLockBanner && (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-center text-sm font-medium text-destructive">
|
||||||
|
حساب شما قفل شده است — رفع محدودیت فقط با بررسی مدیریت انجام میشود.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline underline-offset-2 hover:text-destructive/80"
|
||||||
|
onClick={() => router.push("/lockout")}
|
||||||
|
>
|
||||||
|
صفحه قفل
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6">
|
||||||
|
{allowChildren ? children : null}
|
||||||
|
</main>
|
||||||
|
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
|
||||||
|
<BrandMark size="sm" logoOnly />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
بوتکمپ ۱۲ هفتهای · توسعهیافته توسط امیرحسین پناهیفر
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{user && <ChatWidget />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={cn("inline-block leading-none", className)}
|
||||||
|
style={{ fontSize: size, fontFamily: "'Apple Color Emoji', 'Segoe UI Emoji', sans-serif" }}
|
||||||
|
title={title}
|
||||||
|
role="img"
|
||||||
|
aria-label={emoji}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={emoji}
|
||||||
|
title={title ?? emoji}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
draggable={false}
|
||||||
|
className={cn("inline-block align-[-0.15em] object-contain", className)}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => {
|
||||||
|
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(<span key={`t${i++}`}>{text.slice(last, idx)}</span>);
|
||||||
|
}
|
||||||
|
parts.push(<AppleEmoji key={`e${i++}`} emoji={match[0]} size={size} />);
|
||||||
|
last = idx + match[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) {
|
||||||
|
parts.push(<span key={`t${i++}`}>{text.slice(last)}</span>);
|
||||||
|
}
|
||||||
|
return <span className={className}>{parts}</span>;
|
||||||
|
}
|
||||||
@@ -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" ? (
|
||||||
|
<span dir="ltr" className={cn("font-extrabold tracking-tight", sizeClass[size], className)}>
|
||||||
|
Par<span className="text-primary">gar</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn("font-extrabold tracking-tight", sizeClass[size], className)}>
|
||||||
|
پر<span className="text-primary">گار</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2" aria-label={logoOnly ? label : undefined}>
|
||||||
|
{showLogo && (
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={logoOnly ? label : ""}
|
||||||
|
width={px}
|
||||||
|
height={px}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-xl bg-white object-contain shadow-offset-sm",
|
||||||
|
size === "sm" && "rounded-lg",
|
||||||
|
size === "lg" && "rounded-2xl"
|
||||||
|
)}
|
||||||
|
priority={size === "lg"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!logoOnly && wordmark}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-border py-16 text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1 px-6">
|
||||||
|
<p className="text-sm font-semibold">{title}</p>
|
||||||
|
{description && <p className="text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div className="mt-1">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Props, State> {
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto flex min-h-[50vh] max-w-lg flex-col items-center justify-center gap-4 px-4 py-16 text-center">
|
||||||
|
<p className="text-lg font-bold text-foreground">مشکلی در نمایش این بخش پیش آمد</p>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
صفحه را یکبار تازه کنید. اگر ادامه داشت، کمی بعد دوباره تلاش کنید.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Button type="button" onClick={this.reset}>
|
||||||
|
تلاش دوباره
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => window.location.assign("/")}>
|
||||||
|
بازگشت به خانه
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<header className="nav-glass sticky top-0 z-40 w-full border-b-2 border-border/60">
|
||||||
|
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4 sm:px-6">
|
||||||
|
<Link href="/" className="group flex items-center gap-2.5 transition-transform duration-200 group-hover:-translate-y-0.5">
|
||||||
|
<BrandMark />
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
{pathname !== "/login" && (
|
||||||
|
<Button asChild size="sm" className="font-bold">
|
||||||
|
<Link href="/login">ورود</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="nav-glass sticky top-0 z-40 w-full border-b-2 border-border/60">
|
||||||
|
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/cap" className="group flex items-center gap-2.5 transition-transform duration-200 group-hover:-translate-y-0.5">
|
||||||
|
<BrandMark />
|
||||||
|
</Link>
|
||||||
|
{!locked && (
|
||||||
|
<nav className="ms-4 hidden items-center gap-1 md:flex">
|
||||||
|
{navLinks.map((l) => {
|
||||||
|
const active = pathname === l.href || pathname.startsWith(l.href + "/");
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={l.href}
|
||||||
|
href={l.href}
|
||||||
|
className={cn(
|
||||||
|
"press rounded-lg px-3.5 py-2 text-sm font-bold transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary text-primary-foreground shadow-offset-sm"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{liveUser && (
|
||||||
|
<>
|
||||||
|
<div className="hidden items-center gap-1.5 sm:flex">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2.5 py-1 text-xs font-bold text-accent transition-transform hover:-translate-y-0.5">
|
||||||
|
<Flame className="h-3.5 w-3.5" />
|
||||||
|
{formatFaNumber(liveUser.streakCurrent)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-gold/10 px-2.5 py-1 text-xs font-bold text-gold transition-transform hover:-translate-y-0.5">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{formatFaNumber(liveUser.xp)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold leading-none transition-transform hover:-translate-y-0.5",
|
||||||
|
locked
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: infiniteHearts
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: effHearts === 1
|
||||||
|
? "bg-destructive/10 text-destructive animate-pulse-soft"
|
||||||
|
: "bg-primary/10 text-primary"
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
locked
|
||||||
|
? "حساب محدود است — منتور باید قفل را باز کند"
|
||||||
|
: infiniteHearts
|
||||||
|
? "جان نامحدود"
|
||||||
|
: `${formatFaNumber(effHearts)} از ${formatFaNumber(MAX_HEARTS)} جان`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Heart className={cn("h-3.5 w-3.5", (locked || (!infiniteHearts && effHearts <= 1)) && "fill-current")} />
|
||||||
|
{infiniteHearts ? (
|
||||||
|
<span className="text-sm leading-none">{INFINITE_HEARTS_LABEL}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{formatFaNumber(effHearts)}/{formatFaNumber(MAX_HEARTS)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<NotificationBell />
|
||||||
|
<ThemeToggle />
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer rounded-full ring-2 ring-border ring-offset-2 ring-offset-background focus-visible:outline-none focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
name={liveUser.name ?? "؟"}
|
||||||
|
src={liveUser.avatarPhoto}
|
||||||
|
variant={liveUser.avatarVariant ?? "beam"}
|
||||||
|
palette={liveUser.avatarPalette ? avatarPaletteOf(liveUser.avatarPalette) : undefined}
|
||||||
|
className="h-9 w-9"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64 shadow-offset-sm">
|
||||||
|
<div className="flex items-center gap-3 px-2.5 py-2.5">
|
||||||
|
<UserAvatar
|
||||||
|
name={liveUser.name ?? "؟"}
|
||||||
|
src={liveUser.avatarPhoto}
|
||||||
|
variant={liveUser.avatarVariant ?? "beam"}
|
||||||
|
palette={liveUser.avatarPalette ? avatarPaletteOf(liveUser.avatarPalette) : undefined}
|
||||||
|
className="h-11 w-11"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-extrabold">{liveUser.name}</p>
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground">{liveUser.email}</p>
|
||||||
|
<p className="text-[11px] text-primary">{roleLabel(liveUser.role)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||||
|
<User /> پروفایل من
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
|
||||||
|
<User /> ویرایش آواتار
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{liveUser.role === "admin" && (
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/admin")}>
|
||||||
|
<UserCog /> پنل مدیریت
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{liveUser.role === "mentor" && (
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/admin")}>
|
||||||
|
<UserCog /> پنل منتور
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={logout} className="text-destructive focus:text-destructive">
|
||||||
|
<LogOut /> خروج
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Dialog open={studioOpen} onOpenChange={setStudioOpen}>
|
||||||
|
<DialogContent className="max-h-[85vh] max-w-sm gap-3 overflow-y-auto chat-scroll rounded-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<AvatarStudio user={liveUser} onClose={() => setStudioOpen(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: string): string {
|
||||||
|
if (role === "admin") return "ادمین";
|
||||||
|
if (role === "mentor") return "منتور";
|
||||||
|
return "هنرجو";
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Reveal
|
||||||
|
className={cn(
|
||||||
|
"mb-8 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between",
|
||||||
|
align === "center" && "sm:flex-col sm:items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("max-w-2xl space-y-2", align === "center" && "text-center")}>
|
||||||
|
{kicker && (
|
||||||
|
<p className={cn("section-kicker", align === "center" && "justify-center")}>{kicker}</p>
|
||||||
|
)}
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground sm:text-base">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
key={pathname}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={softPage}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Reveal className={className} delay={delay}>
|
||||||
|
<section className="card-hover overflow-hidden rounded-2xl border-2 border-border bg-card">
|
||||||
|
{(title || actions) && (
|
||||||
|
<header className="flex flex-wrap items-start gap-3 border-b border-border/70 px-5 py-4 sm:px-6">
|
||||||
|
{icon && (
|
||||||
|
<span className={cn("mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl", tint)}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{title && <h2 className="text-base font-bold leading-snug">{title}</h2>}
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex w-full shrink-0 flex-wrap items-center gap-2 sm:ms-auto sm:w-auto sm:justify-end">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className={cn("p-5 sm:p-6", bodyClassName)}>{children}</div>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||||
|
<MotionConfig reducedMotion="user" transition={softTween}>
|
||||||
|
<ErrorBoundary area="app">{children}</ErrorBoundary>
|
||||||
|
</MotionConfig>
|
||||||
|
<Toaster position="top-center" richColors closeButton />
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toast };
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0, y, scale: 0.992 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
viewport={{ once: true, margin: "-40px" }}
|
||||||
|
transition={{ ...softReveal, delay }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(dark ? "light" : "dark")}
|
||||||
|
aria-label="تغییر تم"
|
||||||
|
className="rounded-md"
|
||||||
|
>
|
||||||
|
{dark ? <Sun className="h-[18px] w-[18px]" /> : <Moon className="h-[18px] w-[18px]" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = "Avatar";
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = "AvatarImage";
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = "AvatarFallback";
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
@@ -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<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-border bg-card text-card-foreground shadow-soft",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-xl font-semibold leading-none", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -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<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = "DialogOverlay";
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-popover p-6 shadow-md rounded-md duration-300 sm:max-w-lg data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">بستن</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = "DialogContent";
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = "DialogTitle";
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = "DialogDescription";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
@@ -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<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 8, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-44 overflow-hidden rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = "DropdownMenuContent";
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||||
|
|
||||||
|
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem };
|
||||||
@@ -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<HTMLInputElement>): 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<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
const latin = wantsLatinDigits({ type, ...props });
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
// Fixed height + symmetric vertical padding; avoid clipping Vazirmatn descenders.
|
||||||
|
"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 placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
latin && "font-latin placeholder:font-latin",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -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 (
|
||||||
|
<DatePicker
|
||||||
|
id={id}
|
||||||
|
value={selected}
|
||||||
|
onChange={(date) => {
|
||||||
|
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={[<TimePicker key="time" position="bottom" hideSeconds />]}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = "Label";
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -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<React.InputHTMLAttributes<HTMLInputElement>, "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 (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
type={visible ? "text" : "password"}
|
||||||
|
dir={resolvedDir}
|
||||||
|
autoComplete={autoComplete ?? "current-password"}
|
||||||
|
className={cn(
|
||||||
|
// Physical padding/toggle side so RTL page chrome cannot flip the eye away from the gutter.
|
||||||
|
"font-latin placeholder:font-latin pr-11",
|
||||||
|
isRtl ? "text-right" : "text-left",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
aria-label={visible ? "مخفی کردن رمز" : "نمایش رمز"}
|
||||||
|
aria-pressed={visible}
|
||||||
|
className="absolute right-1.5 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{visible ? <EyeOff className="h-4 w-4" aria-hidden /> : <Eye className="h-4 w-4" aria-hidden />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
PasswordInput.displayName = "PasswordInput";
|
||||||
|
|
||||||
|
export { PasswordInput };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value?: number;
|
||||||
|
gradient?: boolean;
|
||||||
|
/** Softer fill motion for verified-watch / long jumps. */
|
||||||
|
smooth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||||
|
({ className, value = 0, gradient, smooth, ...props }, ref) => {
|
||||||
|
const pct = Math.min(100, Math.max(0, value));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full will-change-[width] rtl:me-auto",
|
||||||
|
gradient ? "bg-primary" : "bg-primary",
|
||||||
|
smooth
|
||||||
|
? "transition-[width] duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
|
: "transition-[width] duration-300 ease-out"
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Progress.displayName = "Progress";
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Skeleton({ className }: { className?: string }) {
|
||||||
|
return <div aria-hidden className={cn("skeleton", className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonText({ className }: { className?: string }) {
|
||||||
|
return <Skeleton className={cn("h-4 rounded-md", className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonAvatar({ className }: { className?: string }) {
|
||||||
|
return <Skeleton className={cn("h-10 w-10 shrink-0 rounded-xl", className)} />;
|
||||||
|
}
|
||||||
@@ -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<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
dir="ltr"
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = "Switch";
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -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<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = "TabsList";
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:border data-[state=active]:border-border",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = "TabsTrigger";
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 focus-visible:outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = "TabsContent";
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[120px] w-full rounded-lg border border-input bg-card px-3.5 py-2.5 font-sans text-sm font-medium leading-normal outline-none transition-colors placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -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 (
|
||||||
|
<Avatar className={cn("ring-1 ring-black/5 dark:ring-white/10", className)}>
|
||||||
|
<AvatarImage src={src} alt={name} />
|
||||||
|
<AvatarFallback />
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-grid shrink-0 place-items-center overflow-hidden rounded-full bg-background ring-1 ring-black/5 dark:ring-white/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BoringAvatar name={name} variant={variant} colors={palette ?? AVATAR_COLORS} size="100%" title={false} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Record<string, number>>({});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<ApiErrorBody> {
|
||||||
|
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<never> {
|
||||||
|
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<void> | null = null;
|
||||||
|
|
||||||
|
export async function refreshSession(): Promise<void> {
|
||||||
|
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<T>(path: string, opts: { method?: string; body?: unknown } = {}): Promise<T> {
|
||||||
|
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: <T>(path: string) => api<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) => api<T>(path, { method: "POST", body }),
|
||||||
|
put: <T>(path: string, body?: unknown) => api<T>(path, { method: "PUT", body }),
|
||||||
|
del: <T>(path: string) => api<T>(path, { method: "DELETE" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Authenticated multipart upload with silent token refresh. */
|
||||||
|
export async function apiForm<T>(path: string, form: FormData): Promise<T> {
|
||||||
|
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<string> {
|
||||||
|
const { url } = await http.get<{ url: string }>(`/api/media/sign?key=${encodeURIComponent(key)}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
@@ -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<AuthState>()(
|
||||||
|
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<AuthState>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user