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>
This commit is contained in:
@@ -11,8 +11,33 @@ export const ROLE_LABELS: Record<Role, string> = {
|
||||
|
||||
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({
|
||||
email: z.email("ایمیل معتبر نیست").trim().toLowerCase(),
|
||||
phone: irMobileSchema,
|
||||
password: z.string().min(1, "رمز عبور را وارد کنید").max(128),
|
||||
});
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
@@ -24,7 +49,7 @@ export const changePasswordSchema = z.object({
|
||||
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|
||||
|
||||
export const userCreateSchema = z.object({
|
||||
email: z.email("ایمیل معتبر نیست").trim().toLowerCase(),
|
||||
phone: irMobileSchema,
|
||||
name: req(80),
|
||||
role: z.enum(ROLES),
|
||||
password,
|
||||
@@ -42,7 +67,7 @@ export type UserUpdateInput = z.infer<typeof userUpdateSchema>;
|
||||
|
||||
export interface AdminUserDto {
|
||||
id: number;
|
||||
email: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
isActive: boolean;
|
||||
|
||||
@@ -3,6 +3,12 @@ import { idSchema, req, slugSchema, text } from "./common.js";
|
||||
|
||||
const shortList = z.array(z.string().trim().min(1).max(120)).max(20).default([]);
|
||||
|
||||
export const productAttributeSchema = z.object({
|
||||
name: req(80),
|
||||
value: req(300),
|
||||
});
|
||||
export type ProductAttribute = z.infer<typeof productAttributeSchema>;
|
||||
|
||||
export const productGroupInputSchema = z.object({
|
||||
/** present when editing an existing group */
|
||||
id: idSchema.optional(),
|
||||
@@ -38,6 +44,18 @@ export const productInputSchema = z.object({
|
||||
desc: text(2000),
|
||||
specs: shortList,
|
||||
variants: shortList,
|
||||
details: text(8000),
|
||||
brand: text(80),
|
||||
origin: text(80),
|
||||
sizes: shortList,
|
||||
standards: shortList,
|
||||
features: z.array(z.string().trim().min(1).max(300)).max(30).default([]),
|
||||
applications: shortList,
|
||||
attributes: z.array(productAttributeSchema).max(40).default([]),
|
||||
packaging: text(200),
|
||||
minOrder: text(120),
|
||||
seoTitle: text(120),
|
||||
seoDescription: text(300),
|
||||
imageId: idSchema.nullable().default(null),
|
||||
galleryIds: z.array(idSchema).max(12).default([]),
|
||||
isPublished: z.boolean().default(true),
|
||||
@@ -94,6 +112,41 @@ export interface PublicCategorySummary {
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
export interface PublicProductImage {
|
||||
url: string;
|
||||
alt: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
/** Everything the product page needs (`GET /api/public/products/:slug`) */
|
||||
export interface PublicProductDetail extends PublicProduct {
|
||||
details: string;
|
||||
brand: string;
|
||||
origin: string;
|
||||
sizes: string[];
|
||||
standards: string[];
|
||||
features: string[];
|
||||
applications: string[];
|
||||
attributes: ProductAttribute[];
|
||||
packaging: string;
|
||||
minOrder: string;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
/** main image first, then the gallery */
|
||||
images: PublicProductImage[];
|
||||
groupName: string | null;
|
||||
category: { slug: string; code: string; name: string; nameEn: string; fullDesc: string; coverImage: string | null };
|
||||
related: PublicProduct[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Published product URLs for the sitemap */
|
||||
export interface PublicProductRef {
|
||||
slug: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PublicCategory extends PublicCategorySummary {
|
||||
fullDesc: string;
|
||||
seoTitle: string;
|
||||
|
||||
Reference in New Issue
Block a user