Files
imen_pishghaman/packages/shared/src/admin.ts
T
S. EsmaeiliandCursor 49f30a96b1 feat: polish admin UI, phone login, and product details
Switch admin auth to mobile, restyle shell/login with Vuesax icons, and add product detail fields plus public product pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 17:18:17 +03:30

155 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { req, text } from "./common.js";
export const ROLES = ["ADMIN", "EDITOR"] as const;
export type Role = (typeof ROLES)[number];
export const ROLE_LABELS: Record<Role, string> = {
ADMIN: "مدیر کل",
EDITOR: "ویرایشگر",
};
const password = z.string().min(8, "رمز عبور باید حداقل ۸ کاراکتر باشد").max(128);
const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
const AR_DIGITS = "٠١٢٣٤٥٦٧٨٩";
/** Normalize Iranian mobile to `09XXXXXXXXX` (Latin digits). */
export function normalizeIrMobile(raw: string): string {
let s = raw.trim().replace(/[\s\-()]/g, "");
s = s.replace(/[۰-۹٠-٩]/g, (d) => {
const fa = FA_DIGITS.indexOf(d);
if (fa >= 0) return String(fa);
const ar = AR_DIGITS.indexOf(d);
return ar >= 0 ? String(ar) : d;
});
if (s.startsWith("+98")) s = `0${s.slice(3)}`;
else if (s.startsWith("0098")) s = `0${s.slice(4)}`;
else if (s.startsWith("98") && s.length === 12) s = `0${s.slice(2)}`;
return s;
}
export const irMobileSchema = z
.string()
.trim()
.min(1, "شماره موبایل را وارد کنید")
.transform(normalizeIrMobile)
.pipe(z.string().regex(/^09\d{9}$/, "شماره موبایل معتبر نیست (مثلاً ۰۹۱۲۱۲۳۴۵۶۷)"));
export const loginSchema = z.object({
phone: irMobileSchema,
password: z.string().min(1, "رمز عبور را وارد کنید").max(128),
});
export type LoginInput = z.infer<typeof loginSchema>;
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "رمز فعلی را وارد کنید").max(128),
newPassword: password,
});
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
export const userCreateSchema = z.object({
phone: irMobileSchema,
name: req(80),
role: z.enum(ROLES),
password,
});
export type UserCreateInput = z.infer<typeof userCreateSchema>;
export const userUpdateSchema = z.object({
name: req(80),
role: z.enum(ROLES),
isActive: z.boolean(),
/** set only when resetting the password */
password: password.optional(),
});
export type UserUpdateInput = z.infer<typeof userUpdateSchema>;
export interface AdminUserDto {
id: number;
phone: string;
name: string;
role: Role;
isActive: boolean;
lastLoginAt: string | null;
createdAt: string;
}
export interface AuthResponse {
accessToken: string;
user: AdminUserDto;
}
/* ---------------- contact messages ---------------- */
export const MESSAGE_STATUSES = ["NEW", "READ", "ARCHIVED"] as const;
export type MessageStatus = (typeof MESSAGE_STATUSES)[number];
export const MESSAGE_STATUS_LABELS: Record<MessageStatus, string> = {
NEW: "جدید",
READ: "خوانده‌شده",
ARCHIVED: "بایگانی",
};
export const contactInputSchema = z.object({
name: req(80),
org: req(120),
phone: z
.string()
.trim()
.min(1, "این فیلد الزامی است")
.max(30)
.regex(/^[0-9۰-۹+\-\s()]{5,30}$/, "شماره تماس معتبر نیست"),
message: text(2000),
/** honeypot — real visitors never fill it */
website: z.string().max(200).optional(),
});
export type ContactInput = z.infer<typeof contactInputSchema>;
export const messageUpdateSchema = z.object({
status: z.enum(MESSAGE_STATUSES),
});
export interface ContactMessageDto {
id: number;
name: string;
org: string;
phone: string;
message: string;
status: MessageStatus;
createdAt: string;
}
/* ---------------- media ---------------- */
export interface MediaDto {
id: number;
url: string;
alt: string;
mime: string;
size: number;
width: number | null;
height: number | null;
originalName: string;
createdAt: string;
}
export const mediaUpdateSchema = z.object({ alt: text(200) });
export interface Paginated<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface DashboardDto {
categories: number;
products: number;
draftProducts: number;
newMessages: number;
media: number;
recentMessages: ContactMessageDto[];
recentActivity: { id: number; action: string; entity: string; summary: string; user: string | null; createdAt: string }[];
}