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,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