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:
ahp
2026-10-07 19:40:26 +03:30
parent 0751ee5588
commit 680db7dce0
39 changed files with 2806 additions and 0 deletions
+270
View File
@@ -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;
}
+63
View File
@@ -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);
}
+32
View File
@@ -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));
}
+37
View File
@@ -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,
};
+45
View File
@@ -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,
},
},
});
+408
View File
@@ -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;
}
+70
View File
@@ -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 };
+30
View File
@@ -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);
}
+133
View File
@@ -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;
}