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:
S. Esmaeili
2026-10-22 17:18:17 +03:30
co-authored by Cursor
parent 789d224ea0
commit 49f30a96b1
54 changed files with 3632 additions and 574 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ JWT_SECRET=change-me-48-random-bytes
REVALIDATE_SECRET=change-me-too
# first admin user (created by: docker compose exec api node dist/seed/seed.js)
ADMIN_EMAIL=admin@imenpishgaman.ir
ADMIN_PHONE=09120000000
ADMIN_PASSWORD=change-me
ADMIN_NAME=مدیر سایت
+4 -3
View File
@@ -59,7 +59,7 @@ cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
pnpm db:migrate # ساخت جدول‌ها
pnpm db:seed # محتوای اولیه + کاربر مدیر (ADMIN_EMAIL / ADMIN_PASSWORD در apps/api/.env)
pnpm db:seed # محتوای اولیه + کاربر مدیر (ADMIN_PHONE / ADMIN_PASSWORD در apps/api/.env)
pnpm dev # هر سه اپ با هم
```
@@ -90,7 +90,7 @@ pnpm dev # هر سه اپ با هم
## پنل مدیریت
- **محصولات**: افزودن/ویرایش، آپلود تصویر اصلی و گالری، ویژگی‌ها و مدل‌ها، انتشار یا پیش‌نویس، کپی، مرتب‌سازی (با فیلتر یک دسته).
- **محصولات**: افزودن/ویرایش، تصویر اصلی و گالری، توضیحات کامل، جدول مشخصات فنی، ویژگی‌های کلیدی، کاربردها، استانداردها، سایزها و مدل‌ها، برند و کشور سازنده، بسته‌بندی و حداقل سفارش، سئو، انتشار یا پیش‌نویس، کپی، مرتب‌سازی (با فیلتر یک دسته). هر محصول صفحه‌ی اختصاصی `/products/<اسلاگ>` دارد (گالری با بزرگ‌نمایی، مشخصات، محصولات مرتبط، دکمه‌ی درخواست قیمت که فرم تماس را با نام محصول پر می‌کند، JSON-LD نوع Product). بخش‌هایی که پر نشده‌اند در صفحه نمایش داده نمی‌شوند.
- **دسته‌بندی‌ها**: کاور، زیرگروه‌ها (فیلترهای صفحه‌ی دسته)، سئو، ترتیب نمایش.
- **بخش‌های سایت**: Hero، نوار متحرک، آمار، معرفی محصولات، درباره ما، خدمات، استانداردها، صنایع، نظرات، تماس، فوتر و تنظیمات سایت (تلفن، ایمیل، نشانی، ساعات کاری، شبکه‌های اجتماعی، سئوی پیش‌فرض).
- **پیام‌های تماس**: درخواست‌های فرم مشاوره‌ی سایت، با وضعیت جدید / خوانده‌شده / بایگانی.
@@ -146,7 +146,8 @@ docker compose exec api node dist/seed/seed.js # فقط بار اول
## کارهای باقی‌مانده و پیشنهادها
- **صفحه‌ی جداگانه برای هر محصول** (`/products/[slug]`) با JSON-LD نوع Product. فیلد `slug` و گالری محصول از الان در دیتابیس هست.
- **پر کردن جزئیات محصولات** (توضیحات کامل، مشخصات فنی، استانداردها و ...) از پنل؛ این فیلدها برای محصولات فعلی خالی‌اند و فعلاً صفحه‌ی محصول توضیح دسته را نشان می‌دهد.
- عکس برای ۱۷ محصول پوشاک کار و تجهیزات ترافیک (فعلاً کاور دسته نمایش داده می‌شود).
- **ایمپورت/اکسپورت اکسل** محصولات برای ورود دسته‌جمعی.
- **اعلان پیام تماس جدید** با ایمیل، پیامک یا ربات بله/تلگرام.
- پوشه‌ی `محصولات/` (عکس‌ها و فایل‌های Word خام) و فایل‌های `_dev.log` و `landing-nextjs-seo.skill` بهتر است از ریپو خارج شوند.
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+13 -1
View File
@@ -1,4 +1,4 @@
import type { MediaDto, SectionKey, SiteSections } from "@imen/shared";
import type { MediaDto, ProductAttribute, SectionKey, SiteSections } from "@imen/shared";
/** Shapes returned by the admin endpoints of the API */
@@ -45,6 +45,18 @@ export interface AdminProduct {
desc: string;
specs: string[];
variants: string[];
details: string;
brand: string;
origin: string;
sizes: string[];
standards: string[];
features: string[];
applications: string[];
attributes: ProductAttribute[] | null;
packaging: string;
minOrder: string;
seoTitle: string;
seoDescription: string;
imageId: number | null;
image: MediaDto | null;
sortOrder: number;
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
/**
* Page shell inside the white panel:
* - optional sticky head (toolbar / filters)
* - body: scrolls by default, or locks height when `lockBody` (table fills + scrolls internally)
*/
defineProps<{ lockBody?: boolean }>();
</script>
<template>
<div class="ap">
<header v-if="$slots.head" class="ap__head">
<slot name="head" />
</header>
<div class="ap__body" :class="{ 'ap__body--locked': lockBody }">
<slot />
</div>
</div>
</template>
<style scoped>
.ap {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.ap__head {
flex-shrink: 0;
padding: 16px 16px 12px;
border-bottom: 1px solid #eef1f6;
background: #ffffff;
}
.ap__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
}
.ap__body--locked {
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
+4 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DeleteOutlined, PictureOutlined } from "@ant-design/icons-vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { MediaDto } from "@imen/shared";
import { ref } from "vue";
import MediaPicker from "./MediaPicker.vue";
@@ -17,15 +17,15 @@ const open = ref(false);
class="flex size-28 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-dashed border-gray-300 bg-gray-50"
>
<img v-if="url" :src="url" alt="" class="size-full object-contain" />
<PictureOutlined v-else class="text-2xl text-gray-300" />
<VuesaxIcon v-else name="gallery" :size="32" class="text-gray-300" />
</div>
<a-space direction="vertical">
<a-button @click="open = true">
<template #icon><PictureOutlined /></template>
<template #icon><VuesaxIcon name="gallery" :size="16" /></template>
{{ url ? "تغییر تصویر" : "انتخاب یا آپلود تصویر" }}
</a-button>
<a-button v-if="url && clearable" danger @click="emit('clear')">
<template #icon><DeleteOutlined /></template>
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
حذف تصویر
</a-button>
</a-space>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { ref, watch } from "vue";
/** Textarea where every non-empty line is one list item (for longer items than tags allow) */
const items = defineModel<string[]>({ required: true });
defineProps<{ placeholder?: string; rows?: number }>();
const parse = (text: string) =>
text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
// keep the raw text locally so blank lines and trailing newlines survive while typing
const text = ref(items.value.join("\n"));
watch(items, (v) => {
if (parse(text.value).join("\n") !== v.join("\n")) text.value = v.join("\n");
});
const onInput = () => {
items.value = parse(text.value);
};
</script>
<template>
<a-textarea
v-model:value="text"
:placeholder="placeholder"
:auto-size="{ minRows: rows ?? 3, maxRows: 14 }"
@change="onInput"
/>
</template>
+5 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts" generic="T">
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
/** Editable list: add, remove and reorder items; each item is rendered by the default slot */
const items = defineModel<T[]>({ required: true });
@@ -41,14 +41,14 @@ const add = () => {
</span>
<a-space :size="4">
<a-button size="small" :disabled="i === 0" title="بالا" @click="move(i, -1)">
<template #icon><ArrowUpOutlined /></template>
<template #icon><VuesaxIcon name="arrow-up" :size="16" /></template>
</a-button>
<a-button size="small" :disabled="i === items.length - 1" title="پایین" @click="move(i, 1)">
<template #icon><ArrowDownOutlined /></template>
<template #icon><VuesaxIcon name="arrow-down" :size="16" /></template>
</a-button>
<a-popconfirm title="این مورد حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(i)">
<a-button size="small" danger title="حذف">
<template #icon><DeleteOutlined /></template>
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
</a-button>
</a-popconfirm>
</a-space>
@@ -56,7 +56,7 @@ const add = () => {
<slot :item="item" :index="i" />
</div>
<a-button type="dashed" block :disabled="items.length >= max" @click="add">
<template #icon><PlusOutlined /></template>
<template #icon><VuesaxIcon name="add" :size="16" /></template>
{{ addLabel }}
</a-button>
</div>
+2 -2
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { InboxOutlined } from "@ant-design/icons-vue";
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { ref, watch } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
/** Modal to upload a new image or pick one from the media library */
const open = defineModel<boolean>("open", { required: true });
@@ -102,7 +102,7 @@ async function upload(file: File) {
:show-upload-list="false"
:before-upload="upload"
>
<p class="ant-upload-drag-icon"><InboxOutlined /></p>
<p class="ant-upload-drag-icon"><VuesaxIcon name="document-upload" :size="40" /></p>
<p class="ant-upload-text">تصویر را اینجا رها کنید یا کلیک کنید</p>
<p class="ant-upload-hint">
JPG، PNG، WebP، AVIF یا GIF — حداکثر ۱۵ مگابایت. تصویر به‌صورت خودکار بهینه و به WebP تبدیل می‌شود.
+55 -7
View File
@@ -3,16 +3,64 @@ defineProps<{ title: string; subtitle?: string; back?: string }>();
</script>
<template>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<RouterLink v-if="back" :to="back" class="text-gray-400 hover:text-gray-700" title="بازگشت">→</RouterLink>
<div>
<h1 class="text-xl font-bold text-gray-800">{{ title }}</h1>
<p v-if="subtitle" class="mt-1 text-sm text-gray-500">{{ subtitle }}</p>
<div class="page-header">
<div class="page-header__main">
<RouterLink v-if="back" :to="back" class="page-header__back" title="بازگشت">→</RouterLink>
<div class="min-w-0">
<h1 class="page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div v-if="$slots.default" class="page-header__actions">
<slot />
</div>
</div>
</template>
<style scoped>
.page-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.page-header__back {
color: #94a3b8;
text-decoration: none;
transition: color 0.15s ease;
}
.page-header__back:hover {
color: #334155;
}
.page-header__title {
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
}
.page-header__subtitle {
margin: 2px 0 0;
font-size: 13px;
color: #94a3b8;
}
.page-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from "vue";
import icons from "@/icons/vuesax-linear.json";
const props = withDefaults(
defineProps<{
/** Short name (`add`) or full key (`vuesax-linear:add`) */
name: string;
size?: number | string;
}>(),
{ size: 18 },
);
const pack = icons as Record<string, string>;
const svgHtml = computed(() => {
const key = props.name.startsWith("vuesax-linear:")
? props.name
: `vuesax-linear:${props.name}`;
const raw = pack[key];
if (!raw) return "";
return raw
.replace(/\swidth="24"/, "")
.replace(/\sheight="24"/, "")
.replace(/stroke="#[A-Fa-f0-9]{3,8}"/g, 'stroke="currentColor"')
.replace(/fill="(?!none)#[A-Fa-f0-9]{3,8}"/g, 'fill="currentColor"');
});
const box = computed(() =>
typeof props.size === "number" ? `${props.size}px` : props.size,
);
</script>
<template>
<span
class="vx-icon"
:style="{ width: box, height: box }"
:aria-hidden="true"
v-html="svgHtml"
/>
</template>
<style scoped>
.vx-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
line-height: 0;
color: inherit;
}
.vx-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { h, type VNode } from "vue";
import VuesaxIcon from "./VuesaxIcon.vue";
/** Render a Vuesax linear icon (for Ant menu `icon` factories, etc.) */
export const vx = (name: string, size = 18): VNode => h(VuesaxIcon, { name, size });
File diff suppressed because one or more lines are too long
+523 -74
View File
@@ -1,20 +1,8 @@
<script setup lang="ts">
import {
AppstoreOutlined,
DashboardOutlined,
ExportOutlined,
FileImageOutlined,
LayoutOutlined,
LogoutOutlined,
MailOutlined,
MenuOutlined,
ShoppingOutlined,
TeamOutlined,
UserOutlined,
} from "@ant-design/icons-vue";
import { ROLE_LABELS } from "@imen/shared";
import { computed, h, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useAuthStore } from "@/stores/auth";
import { SITE_URL } from "@/utils/format";
@@ -23,14 +11,18 @@ const route = useRoute();
const router = useRouter();
const drawer = ref(false);
/** Icon badge box + Vuesax glyph (keeps pre-swap menu structure) */
const menuIcon = (name: string) => () =>
h("span", { class: "admin-menu__icon" }, [h(VuesaxIcon, { name, size: 16 })]);
const items = computed(() => [
{ key: "dashboard", icon: () => h(DashboardOutlined), label: "داشبورد" },
{ key: "products", icon: () => h(ShoppingOutlined), label: "محصولات" },
{ key: "categories", icon: () => h(AppstoreOutlined), label: "دسته‌بندی‌ها" },
{ key: "sections", icon: () => h(LayoutOutlined), label: "بخش‌های سایت" },
{ key: "messages", icon: () => h(MailOutlined), label: "پیام‌های تماس" },
{ key: "media", icon: () => h(FileImageOutlined), label: "کتابخانه رسانه" },
...(auth.isAdmin ? [{ key: "users", icon: () => h(TeamOutlined), label: "کاربران" }] : []),
{ key: "dashboard", icon: menuIcon("element-3"), label: "داشبورد" },
{ key: "products", icon: menuIcon("bag-2"), label: "محصولات" },
{ key: "categories", icon: menuIcon("category"), label: "دسته‌بندی‌ها" },
{ key: "sections", icon: menuIcon("row-vertical"), label: "بخش‌های سایت" },
{ key: "messages", icon: menuIcon("sms"), label: "پیام‌های تماس" },
{ key: "media", icon: menuIcon("gallery"), label: "کتابخانه رسانه" },
...(auth.isAdmin ? [{ key: "users", icon: menuIcon("profile-2user"), label: "کاربران" }] : []),
]);
/** menu entry to highlight for nested pages (products/12 → products) */
@@ -41,11 +33,31 @@ const MENU_OF: Record<string, string> = {
"category-edit": "categories",
"section-edit": "sections",
};
const PAGE_TITLES: Record<string, string> = {
dashboard: "داشبورد",
products: "محصولات",
"product-new": "محصول جدید",
"product-edit": "ویرایش محصول",
categories: "دسته‌بندی‌ها",
"category-new": "دسته‌بندی جدید",
"category-edit": "ویرایش دسته‌بندی",
sections: "بخش‌های سایت",
"section-edit": "ویرایش بخش",
messages: "پیام‌های تماس",
media: "کتابخانه رسانه",
users: "کاربران",
profile: "حساب کاربری",
};
const selected = computed(() => {
const name = String(route.name ?? "");
return [MENU_OF[name] ?? name];
});
const pageTitle = computed(() => PAGE_TITLES[String(route.name ?? "")] ?? "پنل مدیریت");
const userInitial = computed(() => (auth.user?.name?.trim().charAt(0) || "ک").slice(0, 1));
const go = ({ key }: { key: string | number }) => {
drawer.value = false;
void router.push({ name: String(key) });
@@ -55,78 +67,515 @@ watch(() => route.fullPath, () => (drawer.value = false));
</script>
<template>
<a-layout class="min-h-screen">
<a-layout class="admin-shell">
<a-layout-sider
:width="232"
:width="248"
breakpoint="lg"
collapsed-width="0"
:trigger="null"
class="!hidden lg:!block"
style="background: #0b1224"
class="admin-sider !hidden lg:!block"
>
<div class="flex items-center gap-3 px-5 py-5">
<img src="/favicon.svg" alt="" class="size-8" />
<div class="leading-tight text-white">
<div class="font-bold">ایمن پیشگامان</div>
<div class="text-[11px] text-white/50">پنل مدیریت</div>
<div class="admin-sider__brand">
<span class="admin-sider__logo">
<img src="/logo.png" alt="" class="size-7 object-contain" />
</span>
<div class="leading-tight">
<div class="text-[14px] font-bold text-[#0f172a]">ایمن پیشگامان</div>
<div class="text-[10px] font-medium tracking-wide text-[#94a3b8]">پنل مدیریت</div>
</div>
</div>
<a-menu
theme="dark"
mode="inline"
:selected-keys="selected"
:items="items"
style="background: transparent; border: 0"
@click="go"
/>
<div class="admin-sider__nav">
<div class="admin-sider__label">منوی اصلی</div>
<a-menu
class="admin-menu"
mode="inline"
:selected-keys="selected"
:items="items"
@click="go"
/>
</div>
</a-layout-sider>
<a-drawer v-model:open="drawer" placement="right" :width="260" :body-style="{ padding: 0, background: '#0b1224' }" :closable="false">
<a-menu theme="dark" mode="inline" :selected-keys="selected" :items="items" style="background: transparent; border: 0" @click="go" />
<a-drawer
v-model:open="drawer"
placement="right"
:width="280"
:closable="false"
class="admin-drawer"
:body-style="{ padding: 0, background: '#ffffff' }"
>
<div class="admin-sider__brand">
<span class="admin-sider__logo">
<img src="/logo.png" alt="" class="size-7 object-contain" />
</span>
<div class="leading-tight">
<div class="text-[14px] font-bold text-[#0f172a]">ایمن پیشگامان</div>
<div class="text-[10px] font-medium text-[#94a3b8]">پنل مدیریت</div>
</div>
</div>
<div class="admin-sider__nav">
<div class="admin-sider__label">منوی اصلی</div>
<a-menu
class="admin-menu"
mode="inline"
:selected-keys="selected"
:items="items"
@click="go"
/>
</div>
</a-drawer>
<a-layout>
<a-layout-header
class="flex items-center justify-between gap-4 border-b border-gray-200 !bg-white !px-4 md:!px-6"
style="height: 60px; line-height: 60px"
>
<div class="flex items-center gap-3">
<a-button class="lg:!hidden" @click="drawer = true">
<template #icon><MenuOutlined /></template>
<a-layout-header class="admin-header">
<div class="admin-header__start">
<a-button class="admin-header__menu-btn lg:!hidden" @click="drawer = true">
<template #icon><VuesaxIcon name="menu" :size="18" /></template>
</a-button>
<a :href="SITE_URL" target="_blank" rel="noopener" class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-[#ff5a00]">
<ExportOutlined />
مشاهده سایت
</a>
<div class="admin-header__crumb">
<span class="admin-header__crumb-kicker">پنل مدیریت</span>
<span class="admin-header__crumb-sep" aria-hidden="true">/</span>
<span class="admin-header__crumb-page">{{ pageTitle }}</span>
</div>
</div>
<a-dropdown placement="bottomLeft">
<a-button type="text" class="flex items-center gap-2">
<UserOutlined />
<span>{{ auth.user?.name }}</span>
<a-tag v-if="auth.user" class="!m-0" :color="auth.isAdmin ? 'orange' : 'blue'">
{{ ROLE_LABELS[auth.user.role] }}
</a-tag>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="profile" @click="router.push({ name: 'profile' })">
<UserOutlined /> حساب کاربری
</a-menu-item>
<a-menu-item key="logout" danger @click="auth.logout()">
<LogoutOutlined /> خروج
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div class="admin-header__end">
<a
:href="SITE_URL"
target="_blank"
rel="noopener"
class="admin-header__site"
>
<VuesaxIcon name="export" :size="16" />
<span>مشاهده سایت</span>
</a>
<div class="admin-header__divider" aria-hidden="true" />
<a-dropdown placement="bottomLeft" :trigger="['click']">
<button type="button" class="admin-header__user">
<span class="admin-header__avatar">{{ userInitial }}</span>
<span class="admin-header__user-meta">
<span class="admin-header__user-name">{{ auth.user?.name }}</span>
<span v-if="auth.user" class="admin-header__user-role">
{{ ROLE_LABELS[auth.user.role] }}
</span>
</span>
<VuesaxIcon name="arrow-down" :size="12" class="admin-header__chevron" />
</button>
<template #overlay>
<a-menu class="admin-header__dropdown">
<a-menu-item key="profile" @click="router.push({ name: 'profile' })">
<VuesaxIcon name="user" :size="16" /> حساب کاربری
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" danger @click="auth.logout()">
<VuesaxIcon name="logout" :size="16" /> خروج
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<a-layout-content class="p-4 md:p-6">
<!-- keyed by path (not query) so /sections/hero → /sections/about gets a fresh page -->
<RouterView v-slot="{ Component, route: r }">
<component :is="Component" :key="r.path" />
</RouterView>
<a-layout-content class="admin-content">
<div class="admin-page">
<RouterView v-slot="{ Component, route: r }">
<component :is="Component" :key="r.path" />
</RouterView>
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
.admin-shell {
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.admin-shell > :deep(.ant-layout) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.admin-header {
position: sticky;
top: 0;
z-index: 20;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 64px !important;
line-height: 1.3 !important;
padding-inline: 16px 20px !important;
background: rgba(255, 255, 255, 0.96) !important;
border-bottom: 1px solid #e8ecf2;
backdrop-filter: blur(10px);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
}
.admin-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 16px;
background: #f5f6fa;
box-sizing: border-box;
}
/* White panel locked to remaining viewport; pages manage their own inner scroll */
.admin-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: #ffffff;
border: 1px solid #e8ecf2;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.admin-page > :deep(*) {
flex: 1;
min-width: 0;
min-height: 0;
}
/* Pages that don't use AdminPage still scroll inside the panel */
.admin-page > :deep(*:not(.ap)) {
overflow: auto;
padding: 16px;
box-sizing: border-box;
}
@media (min-width: 768px) {
.admin-header {
padding-inline: 24px !important;
}
}
.admin-header__start,
.admin-header__end {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.admin-header__menu-btn {
border-radius: 10px !important;
border-color: #e2e8f0 !important;
color: #475569 !important;
}
.admin-header__crumb {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-header__crumb-kicker {
display: none;
font-size: 12px;
font-weight: 500;
color: #94a3b8;
}
@media (min-width: 640px) {
.admin-header__crumb-kicker {
display: inline;
}
}
.admin-header__crumb-sep {
display: none;
color: #cbd5e1;
}
@media (min-width: 640px) {
.admin-header__crumb-sep {
display: inline;
}
}
.admin-header__crumb-page {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.admin-header__site {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding-inline: 14px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #ffffff;
color: #475569;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition:
color 0.18s ease,
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
}
.admin-header__site:hover {
color: #e04f00;
border-color: #ffd0b5;
background: #fff7f2;
box-shadow: 0 2px 8px rgba(255, 90, 0, 0.08);
}
.admin-header__divider {
width: 1px;
height: 28px;
background: #e8ecf2;
}
.admin-header__user {
display: inline-flex;
align-items: center;
gap: 10px;
max-width: 260px;
height: 44px;
padding-block: 4px;
padding-inline: 4px 10px;
border: 1px solid #e8ecf2;
border-radius: 999px;
background: #f8fafc;
color: inherit;
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
}
.admin-header__user:hover {
background: #ffffff;
border-color: #ffd0b5;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
}
.admin-header__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 999px;
background: linear-gradient(135deg, #ff5a00, #ff8a3d);
color: #ffffff;
font-size: 14px;
font-weight: 700;
box-shadow: 0 2px 8px rgba(255, 90, 0, 0.28);
}
.admin-header__user-meta {
display: none;
flex-direction: column;
align-items: flex-start;
min-width: 0;
line-height: 1.25;
}
@media (min-width: 640px) {
.admin-header__user-meta {
display: flex;
}
}
.admin-header__user-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.admin-header__user-role {
font-size: 11px;
font-weight: 500;
color: #94a3b8;
}
.admin-header__chevron {
font-size: 10px;
color: #94a3b8;
}
.admin-sider {
height: 100vh !important;
height: 100dvh !important;
max-height: 100vh !important;
max-height: 100dvh !important;
position: sticky !important;
top: 0;
background: #ffffff !important;
/* content-facing edge (left in RTL) — continuous vertical rail */
border-inline-end: 1px solid #e8ecf2;
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.04);
}
.admin-sider :deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.admin-sider__brand {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 10px;
height: 64px;
padding: 0 16px;
border-bottom: 1px solid #eef1f6;
box-sizing: border-box;
}
.admin-sider__logo {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #fff7f2;
border: 1px solid #ffe0cc;
box-shadow: 0 1px 2px rgba(255, 90, 0, 0.06);
}
.admin-sider__nav {
flex: 1;
min-height: 0;
height: calc(100vh - 64px);
height: calc(100dvh - 64px);
padding: 16px 12px 20px;
overflow-x: hidden;
overflow-y: auto;
}
.admin-sider__label {
margin: 0 8px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: #94a3b8;
}
.admin-menu {
background: transparent !important;
border: 0 !important;
}
.admin-menu :deep(.ant-menu-item) {
display: flex;
align-items: center;
height: 44px !important;
margin: 0 0 6px !important;
padding-inline: 12px !important;
border: 1px solid transparent !important;
border-radius: 12px !important;
color: #475569 !important;
font-weight: 500;
line-height: 44px !important;
transition:
background 0.2s ease,
color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.admin-menu :deep(.ant-menu-item-icon),
.admin-menu :deep(.ant-menu-item .anticon) {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: auto !important;
height: auto !important;
margin-inline-end: 0 !important;
font-size: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.admin-menu :deep(.admin-menu__icon) {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-inline-end: 10px;
border-radius: 9px;
background: #f1f5f9;
color: #64748b;
transition:
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
}
.admin-menu :deep(.admin-menu__icon .vx-icon) {
width: 16px !important;
height: 16px !important;
background: transparent !important;
box-shadow: none !important;
color: inherit;
}
.admin-menu :deep(.ant-menu-item:hover) {
background: #f8fafc !important;
color: #0f172a !important;
border-color: #eef1f6 !important;
}
.admin-menu :deep(.ant-menu-item:hover .admin-menu__icon) {
background: #e8eef7;
color: #475569;
}
.admin-menu :deep(.ant-menu-item-selected) {
background: #fff7f2 !important;
border-color: #ffd7bf !important;
color: #c2410c !important;
font-weight: 700;
box-shadow: 0 4px 14px rgba(255, 90, 0, 0.1);
}
.admin-menu :deep(.ant-menu-item-selected .admin-menu__icon) {
background: #ff5a00 !important;
color: #ffffff !important;
box-shadow: 0 4px 10px rgba(255, 90, 0, 0.32);
}
.admin-menu :deep(.ant-menu-item-selected::after) {
display: none !important;
}
</style>
+7 -14
View File
@@ -1,15 +1,8 @@
<script setup lang="ts">
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
EditOutlined,
ExportOutlined,
PlusOutlined,
} from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { AdminCategory } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { faNumber, SITE_URL } from "@/utils/format";
@@ -68,7 +61,7 @@ onMounted(load);
<PageHeader title="دسته‌بندی‌ها" subtitle="ترتیب دسته‌ها همان ترتیب نمایش در سایت است">
<RouterLink :to="{ name: 'category-new' }">
<a-button type="primary">
<template #icon><PlusOutlined /></template>
<template #icon><VuesaxIcon name="add" :size="16" /></template>
دسته جدید
</a-button>
</RouterLink>
@@ -99,20 +92,20 @@ onMounted(load);
<template v-else-if="column.key === 'order'">
<a-space :size="4">
<a-button size="small" :disabled="index === 0" @click="move(index, -1)">
<template #icon><ArrowUpOutlined /></template>
<template #icon><VuesaxIcon name="arrow-up" :size="16" /></template>
</a-button>
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
<template #icon><ArrowDownOutlined /></template>
<template #icon><VuesaxIcon name="arrow-down" :size="16" /></template>
</a-button>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<RouterLink :to="{ name: 'category-edit', params: { id: record.id } }">
<a-button size="small"><template #icon><EditOutlined /></template></a-button>
<a-button size="small"><template #icon><VuesaxIcon name="edit-2" :size="16" /></template></a-button>
</RouterLink>
<a :href="`${SITE_URL}/categories/${record.slug}`" target="_blank" rel="noopener">
<a-button size="small" title="مشاهده در سایت"><template #icon><ExportOutlined /></template></a-button>
<a-button size="small" title="مشاهده در سایت"><template #icon><VuesaxIcon name="export" :size="16" /></template></a-button>
</a>
<a-popconfirm
title="این دسته حذف شود؟"
@@ -121,7 +114,7 @@ onMounted(load);
@confirm="remove(record as AdminCategory)"
>
<a-button size="small" danger :disabled="record.productCount > 0" :title="record.productCount > 0 ? 'ابتدا محصولات این دسته را حذف یا جابه‌جا کنید' : 'حذف'">
<template #icon><DeleteOutlined /></template>
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
</a-button>
</a-popconfirm>
</a-space>
+2 -2
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { SaveOutlined } from "@ant-design/icons-vue";
import { categoryInputSchema, type MediaDto } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { z } from "zod";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { AdminCategoryDetail } from "@/api/types";
import FormField from "@/components/FormField.vue";
import IconSelect from "@/components/IconSelect.vue";
@@ -108,7 +108,7 @@ onMounted(load);
<template>
<PageHeader :title="id ? `ویرایش دسته: ${model.name}` : 'دسته جدید'" back="/categories">
<a-button type="primary" :loading="saving" @click="save">
<template #icon><SaveOutlined /></template>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</PageHeader>
+2 -2
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { PlusOutlined } from "@ant-design/icons-vue";
import { type DashboardDto, MESSAGE_STATUS_LABELS } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { activityLabel, faNumber, jalali } from "@/utils/format";
@@ -22,7 +22,7 @@ onMounted(async () => {
<PageHeader title="داشبورد" subtitle="خلاصه وضعیت محتوای سایت">
<RouterLink :to="{ name: 'product-new' }">
<a-button type="primary">
<template #icon><PlusOutlined /></template>
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
+174 -21
View File
@@ -1,16 +1,16 @@
<script setup lang="ts">
import { LockOutlined, MailOutlined } from "@ant-design/icons-vue";
import { loginSchema } from "@imen/shared";
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import FormField from "@/components/FormField.vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const { model, validate, handleError } = useZodForm(loginSchema, { email: "", password: "" });
const { model, validate, handleError } = useZodForm(loginSchema, { phone: "", password: "" });
const loading = ref(false);
async function submit() {
@@ -30,26 +30,179 @@ async function submit() {
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-[#0b1224] p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-2xl">
<div class="mb-8 text-center">
<img src="/favicon.svg" alt="" class="mx-auto size-12" />
<h1 class="mt-4 text-xl font-bold text-gray-800">پنل مدیریت ایمن پیشگامان</h1>
<p class="mt-1 text-sm text-gray-500">برای ادامه وارد شوید</p>
<div class="login-page relative flex min-h-screen overflow-hidden text-[#edede6]">
<!-- atmosphere -->
<div class="pointer-events-none absolute inset-0 bg-[#0b1224]" />
<div class="login-grid pointer-events-none absolute inset-0 opacity-50" />
<div
class="pointer-events-none absolute -start-24 top-1/4 size-[420px] rounded-full bg-[#ff5a00]/15 blur-[120px]"
/>
<div
class="pointer-events-none absolute -end-16 bottom-0 size-[360px] rounded-full bg-[#ffb800]/10 blur-[100px]"
/>
<div class="login-tape pointer-events-none absolute bottom-0 end-0 z-10 h-48 w-1.5 opacity-70" />
<div class="login-noise pointer-events-none absolute inset-0 z-[5]" />
<div
class="relative z-10 mx-auto grid w-full max-w-[1120px] flex-1 items-center gap-10 px-5 py-12 md:px-8 lg:grid-cols-12 lg:gap-16"
>
<!-- form column -->
<div class="login-fade-3 lg:col-span-6">
<div
class="login-panel w-full max-w-md border border-[#2a3d63]/70 bg-[#1a2744]/80 p-7 backdrop-blur-md md:p-8 lg:me-auto"
>
<div class="mb-7 text-center">
<div class="font-mono text-[10px] tracking-[0.28em] text-[#8e9ab0]">SIGN IN</div>
<h2 class="mt-2 text-xl font-bold text-[#edede6]">ورود به پنل</h2>
<p class="mt-1.5 text-sm text-[#8e9ab0]">شماره موبایل و رمز عبور خود را وارد کنید</p>
</div>
<a-form class="login-form" layout="vertical" @submit.prevent="submit">
<FormField path="phone" label="شماره موبایل">
<a-input
v-model:value="model.phone"
size="large"
class="ltr-input"
autocomplete="tel"
autofocus
inputmode="tel"
placeholder="09121234567"
>
<template #prefix><VuesaxIcon name="mobile" :size="16" class="text-[#8e9ab0]" /></template>
</a-input>
</FormField>
<FormField path="password" label="رمز عبور">
<a-input-password
v-model:value="model.password"
size="large"
class="ltr-input"
autocomplete="current-password"
placeholder="••••••••"
>
<template #prefix><VuesaxIcon name="lock" :size="16" class="text-[#8e9ab0]" /></template>
</a-input-password>
</FormField>
<a-button
type="primary"
html-type="submit"
size="large"
block
:loading="loading"
class="!mt-2 !h-12 !rounded-full !font-bold !shadow-[0_8px_32px_rgba(255,90,0,0.35)]"
>
ورود به پنل
</a-button>
</a-form>
</div>
</div>
<!-- brand column -->
<div class="login-fade-1 lg:col-span-6">
<div
class="inline-flex items-center gap-2.5 rounded-full border border-[#ff5a00]/30 bg-[#ff5a00]/10 px-4 py-2 backdrop-blur"
>
<span class="size-1.5 animate-pulse rounded-full bg-[#ff5a00]" />
<span class="text-xs font-medium text-[#ff5a00]">پنل مدیریت ایمن پیشگامان</span>
</div>
<div class="mt-8 flex items-center gap-4">
<span
class="flex size-14 items-center justify-center rounded-xl bg-white shadow-[0_0_28px_rgba(10,30,92,0.45)]"
>
<img src="/logo.png" alt="ایمن پیشگامان" class="size-9 object-contain" />
</span>
<div class="leading-tight">
<div class="text-2xl font-black tracking-tight md:text-3xl">ایمن پیشگامان</div>
<div class="mt-1 font-mono text-[10px] tracking-[0.32em] text-[#8e9ab0]">
IMEN PISHGAMAN CO.
</div>
</div>
</div>
<h1 class="mt-8 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]">
ورود امن
<span
class="mt-2 inline-block -rotate-1 rounded-lg bg-[#ffb800] px-3 py-1 text-[#0b1224] shadow-[0_10px_40px_rgba(255,184,0,0.22)]"
>
به سامانه
</span>
</h1>
<p class="mt-6 max-w-md text-sm leading-8 text-[#edede6]/70 md:text-base md:leading-9">
مدیریت محصولات، محتوا و پیام‌های سایت — فقط برای کاربران مجاز.
</p>
</div>
<a-form layout="vertical" @submit.prevent="submit">
<FormField path="email" label="ایمیل">
<a-input v-model:value="model.email" size="large" class="ltr-input" autocomplete="username" autofocus>
<template #prefix><MailOutlined class="text-gray-400" /></template>
</a-input>
</FormField>
<FormField path="password" label="رمز عبور">
<a-input-password v-model:value="model.password" size="large" class="ltr-input" autocomplete="current-password">
<template #prefix><LockOutlined class="text-gray-400" /></template>
</a-input-password>
</FormField>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">ورود</a-button>
</a-form>
</div>
</div>
</template>
<style scoped>
.login-grid {
background-image:
linear-gradient(to left, rgba(237, 237, 230, 0.045) 1px, transparent 1px),
linear-gradient(to bottom, rgba(237, 237, 230, 0.045) 1px, transparent 1px);
background-size: 72px 72px;
}
.login-tape {
background-image: repeating-linear-gradient(45deg, #ffb800 0 10px, #0b1224 10px 20px);
}
.login-noise {
opacity: 0.045;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.login-fade-1,
.login-fade-3 {
animation: login-fade-up 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.login-fade-1 {
animation-delay: 0.12s;
}
.login-fade-3 {
animation-delay: 0.28s;
}
@keyframes login-fade-up {
from {
opacity: 0;
transform: translateY(36px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-panel :deep(.ant-form-item-label > label) {
color: #edede6 !important;
}
.login-panel :deep(.ant-input-affix-wrapper),
.login-panel :deep(.ant-input) {
background: #152038 !important;
border-color: #2a3d63 !important;
color: #edede6 !important;
}
.login-panel :deep(.ant-input-affix-wrapper:hover),
.login-panel :deep(.ant-input-affix-wrapper-focused),
.login-panel :deep(.ant-input-affix-wrapper:focus-within) {
border-color: #ff5a00 !important;
box-shadow: 0 0 0 2px rgba(255, 90, 0, 0.15) !important;
}
.login-panel :deep(.ant-input::placeholder) {
color: #8e9ab0 !important;
}
.login-panel :deep(.ant-input-password-icon) {
color: #8e9ab0 !important;
}
.login-panel :deep(.ant-form-item-explain-error) {
color: #ff7a2a;
}
</style>
+4 -4
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { CopyOutlined, DeleteOutlined, UploadOutlined } from "@ant-design/icons-vue";
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { faNumber, fileSize, jalali } from "@/utils/format";
@@ -89,7 +89,7 @@ onMounted(load);
<PageHeader title="کتابخانه رسانه" :subtitle="`${faNumber(total)} تصویر`">
<a-upload accept="image/jpeg,image/png,image/webp,image/avif,image/gif" multiple :show-upload-list="false" :before-upload="upload">
<a-button type="primary" :loading="uploading > 0">
<template #icon><UploadOutlined /></template>
<template #icon><VuesaxIcon name="document-upload" :size="16" /></template>
آپلود تصویر
</a-button>
</a-upload>
@@ -140,9 +140,9 @@ onMounted(load);
</a-form-item>
<a-space wrap>
<a-button type="primary" @click="saveAlt">ذخیره</a-button>
<a-button @click="copyUrl(selected.url)"><template #icon><CopyOutlined /></template>کپی آدرس</a-button>
<a-button @click="copyUrl(selected.url)"><template #icon><VuesaxIcon name="copy" :size="16" /></template>کپی آدرس</a-button>
<a-popconfirm title="این تصویر حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove">
<a-button danger><template #icon><DeleteOutlined /></template>حذف</a-button>
<a-button danger><template #icon><VuesaxIcon name="trash" :size="16" /></template>حذف</a-button>
</a-popconfirm>
</a-space>
</a-form>
+2 -2
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { DeleteOutlined } from "@ant-design/icons-vue";
import {
type ContactMessageDto,
MESSAGE_STATUS_LABELS,
@@ -11,6 +10,7 @@ import { message } from "ant-design-vue";
import { onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { faNumber, jalali } from "@/utils/format";
@@ -117,7 +117,7 @@ onMounted(load);
</template>
<template v-else-if="column.key === 'actions'">
<a-popconfirm title="این پیام حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record as ContactMessageDto)">
<a-button size="small" danger><template #icon><DeleteOutlined /></template></a-button>
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
</a-popconfirm>
</template>
</template>
+114 -13
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { CloseOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons-vue";
import { type MediaDto, productInputSchema } from "@imen/shared";
import { type MediaDto, type ProductAttribute, productInputSchema } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { z } from "zod";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { AdminCategory, AdminCategoryDetail, AdminProductDetail } from "@/api/types";
import FormField from "@/components/FormField.vue";
import ImageField from "@/components/ImageField.vue";
import LinesInput from "@/components/LinesInput.vue";
import ListEditor from "@/components/ListEditor.vue";
import MediaPicker from "@/components/MediaPicker.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
@@ -31,6 +33,18 @@ const empty = (): Form => ({
desc: "",
specs: [],
variants: [],
details: "",
brand: "",
origin: "",
sizes: [],
standards: [],
features: [],
applications: [],
attributes: [],
packaging: "",
minOrder: "",
seoTitle: "",
seoDescription: "",
imageId: null,
galleryIds: [],
isPublished: true,
@@ -51,6 +65,7 @@ const groupId = computed({
get: () => model.value.groupId ?? undefined,
set: (v: number | undefined) => (model.value.groupId = v ?? null),
});
const newAttribute = (): ProductAttribute => ({ name: "", value: "" });
async function loadGroups(clear: boolean) {
if (clear) model.value.groupId = null;
@@ -75,6 +90,18 @@ async function load() {
desc: p.desc,
specs: p.specs,
variants: p.variants,
details: p.details,
brand: p.brand,
origin: p.origin,
sizes: p.sizes,
standards: p.standards,
features: p.features,
applications: p.applications,
attributes: p.attributes ?? [],
packaging: p.packaging,
minOrder: p.minOrder,
seoTitle: p.seoTitle,
seoDescription: p.seoDescription,
imageId: p.imageId,
galleryIds: p.gallery.map((g) => g.id),
isPublished: p.isPublished,
@@ -135,15 +162,15 @@ onMounted(load);
<template>
<PageHeader :title="id ? `ویرایش محصول: ${model.name}` : 'محصول جدید'" back="/products">
<a
v-if="id && category && model.isPublished"
:href="`${SITE_URL}/categories/${category.slug}`"
v-if="id && category?.isPublished && model.isPublished"
:href="`${SITE_URL}/products/${model.slug}`"
target="_blank"
rel="noopener"
>
<a-button>مشاهده در سایت</a-button>
</a>
<a-button type="primary" :loading="saving" @click="save">
<template #icon><SaveOutlined /></template>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</PageHeader>
@@ -163,26 +190,78 @@ onMounted(load);
<FormField path="code" label="کد محصول" required hint="یکتا، مثلاً IP-G20">
<a-input v-model:value="model.code" class="ltr-input" />
</FormField>
<FormField path="slug" label="اسلاگ" required>
<FormField path="slug" label="اسلاگ (آدرس صفحه)" required :hint="`آدرس: /products/${model.slug || '...'}`">
<a-input v-model:value="model.slug" class="ltr-input">
<template #suffix>
<a class="text-xs" @click="model.slug = toSlug(model.code || model.nameEn || '')">ساخت از کد</a>
</template>
</a-input>
</FormField>
<FormField path="desc" label="توضیحات" class="md:col-span-2">
<a-textarea v-model:value="model.desc" :auto-size="{ minRows: 3, maxRows: 8 }" />
<FormField path="desc" label="توضیح کوتاه" hint="زیر عنوان صفحه محصول و روی کارت محصول نمایش داده می‌شود" class="md:col-span-2">
<a-textarea v-model:value="model.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
<FormField path="specs" label="ویژگی‌ها" hint="هر ویژگی را بنویسید و Enter بزنید" class="md:col-span-2">
<FormField path="specs" label="برچسب‌های کوتاه" hint="ویژگی‌های یک‌کلمه‌ای روی کارت؛ هر مورد را بنویسید و Enter بزنید" class="md:col-span-2">
<a-select v-model:value="model.specs" mode="tags" :open="false" placeholder="مثلاً: ضد برش" />
</FormField>
<FormField path="variants" label="مدل‌ها / رنگ‌ها" hint="اختیاری — هر مورد را بنویسید و Enter بزنید" class="md:col-span-2">
</div>
</a-card>
<a-card title="توضیحات کامل (صفحه محصول)">
<FormField path="details" hint="برای شروع پاراگراف جدید یک خط خالی بگذارید. اگر خالی بماند، توضیح دسته نمایش داده می‌شود.">
<a-textarea
v-model:value="model.details"
:auto-size="{ minRows: 6, maxRows: 20 }"
placeholder="معرفی محصول، جنس و ساختار، مزایا، نکات نگهداری و ..."
/>
</FormField>
</a-card>
<a-card title="ویژگی‌ها و کاربردها">
<FormField path="features" label="ویژگی‌های کلیدی" hint="هر ویژگی در یک خط">
<LinesInput v-model="model.features" :rows="4" placeholder="مثلاً: زیره ضد لغزش و مقاوم در برابر روغن" />
</FormField>
<FormField path="applications" label="کاربردها / محیط‌های مناسب" hint="هر مورد را بنویسید و Enter بزنید">
<a-select v-model:value="model.applications" mode="tags" :open="false" placeholder="مثلاً: پالایشگاه" />
</FormField>
</a-card>
<a-card title="مشخصات فنی" :body-style="{ background: '#fafafa' }">
<p class="mb-4 text-sm text-gray-500">
ردیف‌های جدول مشخصات صفحه محصول (مثلاً «جنس رویه» ← «چرم طبیعی»). کد، دسته، برند و موارد زیر به‌صورت خودکار به جدول اضافه می‌شوند.
</p>
<ListEditor
v-model="model.attributes"
:create="newAttribute"
:max="40"
add-label="افزودن ردیف مشخصات"
:item-title="(a) => a.name"
>
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-5">
<FormField :path="`attributes.${index}.name`" label="عنوان" required class="md:col-span-2">
<a-input v-model:value="item.name" placeholder="مثلاً: جنس رویه" />
</FormField>
<FormField :path="`attributes.${index}.value`" label="مقدار" required class="md:col-span-3">
<a-input v-model:value="item.value" placeholder="مثلاً: چرم طبیعی گاوی" />
</FormField>
</div>
</template>
</ListEditor>
<div class="mt-6 grid gap-x-4 md:grid-cols-2">
<FormField path="standards" label="استانداردها و گواهی‌ها" hint="مثلاً EN 388 — هر مورد + Enter">
<a-select v-model:value="model.standards" mode="tags" :open="false" class="ltr-input" />
</FormField>
<FormField path="sizes" label="سایزها" hint="مثلاً 40، 41، XL — هر مورد + Enter">
<a-select v-model:value="model.sizes" mode="tags" :open="false" />
</FormField>
<FormField path="variants" label="مدل‌ها / رنگ‌ها" hint="هر مورد + Enter" class="md:col-span-2">
<a-select v-model:value="model.variants" mode="tags" :open="false" placeholder="مثلاً: مشکی/زرد" />
</FormField>
</div>
</a-card>
<a-card title="گالری تصاویر (اختیاری)">
<a-card title="گالری تصاویر">
<p class="mb-4 text-sm text-gray-500">تصاویر بیشتر محصول؛ در صفحه محصول بعد از تصویر اصلی نمایش داده می‌شوند.</p>
<div class="flex flex-wrap gap-3">
<div v-for="m in gallery" :key="m.id" class="relative size-24 overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
<img :src="m.url" alt="" class="size-full object-contain" />
@@ -192,7 +271,7 @@ onMounted(load);
title="حذف از گالری"
@click="removeFromGallery(m.id)"
>
<CloseOutlined class="text-xs" />
<VuesaxIcon name="close-circle" :size="16" class="text-xs" />
</button>
</div>
<button
@@ -200,7 +279,7 @@ onMounted(load);
class="flex size-24 items-center justify-center rounded-lg border border-dashed border-gray-300 text-gray-400 hover:border-[#ff5a00] hover:text-[#ff5a00]"
@click="galleryPicker = true"
>
<PlusOutlined />
<VuesaxIcon name="add" :size="16" />
</button>
</div>
<MediaPicker v-model:open="galleryPicker" @select="addToGallery" />
@@ -238,6 +317,28 @@ onMounted(load);
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
</FormField>
</a-card>
<a-card title="برند و سفارش">
<FormField path="brand" label="برند">
<a-input v-model:value="model.brand" />
</FormField>
<FormField path="origin" label="کشور سازنده">
<a-input v-model:value="model.origin" placeholder="مثلاً: ایران" />
</FormField>
<FormField path="packaging" label="بسته‌بندی">
<a-input v-model:value="model.packaging" placeholder="مثلاً: کارتن ۱۲ جفتی" />
</FormField>
<FormField path="minOrder" label="حداقل سفارش">
<a-input v-model:value="model.minOrder" placeholder="مثلاً: ۱ کارتن" />
</FormField>
</a-card>
<a-card title="سئو">
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام محصول">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField>
<FormField path="seoDescription" label="توضیحات متا" hint="خالی = توضیح کوتاه">
<a-textarea v-model:value="model.seoDescription" :maxlength="300" show-count :auto-size="{ minRows: 3 }" />
</FormField>
</a-card>
</div>
</div>
</a-form>
+316 -130
View File
@@ -1,31 +1,41 @@
<script setup lang="ts">
import {
ArrowDownOutlined,
ArrowUpOutlined,
CopyOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
} from "@ant-design/icons-vue";
import type { Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api, errorMessage } from "@/api/http";
import type { AdminCategory, AdminCategoryDetail, AdminProduct } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { faNumber } from "@/utils/format";
import AdminPage from "@/components/AdminPage.vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
const route = useRoute();
const router = useRouter();
const tableShell = ref<HTMLElement | null>(null);
const scrollY = ref(360);
let tableResizeObserver: ResizeObserver | null = null;
function measureTableScroll() {
const el = tableShell.value;
if (!el) return;
const pagination = el.querySelector(".ant-table-pagination") as HTMLElement | null;
const header = el.querySelector(".ant-table-thead") as HTMLElement | null;
const paginationH = pagination?.offsetHeight ?? (reorderable.value ? 0 : 48);
const headerH = header?.offsetHeight ?? 55;
const gap = 16;
scrollY.value = Math.max(180, el.clientHeight - paginationH - headerH - gap);
}
const num = (v: unknown) => (v ? Number(v) : undefined);
const PAGE_SIZE_OPTIONS = ["10", "20", "50", "100"];
const DEFAULT_PAGE_SIZE = 20;
const filters = reactive({
categoryId: num(route.query.categoryId),
groupId: num(route.query.groupId),
status: (route.query.status as "published" | "draft" | undefined) || undefined,
q: typeof route.query.q === "string" ? route.query.q : "",
page: num(route.query.page) ?? 1,
pageSize: num(route.query.pageSize) ?? DEFAULT_PAGE_SIZE,
});
const rows = ref<AdminProduct[]>([]);
@@ -33,26 +43,67 @@ const total = ref(0);
const loading = ref(false);
const categories = ref<AdminCategory[]>([]);
const groups = ref<AdminCategoryDetail["groups"]>([]);
const filtersOpen = ref(false);
const activeFilterCount = computed(
() => [filters.categoryId, filters.groupId, filters.status].filter(Boolean).length,
);
/** products without their own photo show the category cover (same fallback as the website) */
const thumbOf = (row: AdminProduct) =>
row.image ? { url: row.image.url, fallback: false } : {
url: categories.value.find((c) => c.id === row.categoryId)?.coverImage?.url ?? null,
fallback: true,
};
row.image
? { url: row.image.url, fallback: false }
: {
url: categories.value.find((c) => c.id === row.categoryId)?.coverImage?.url ?? null,
fallback: true,
};
/** with a single category selected (and no other filter) the whole list is shown and can be reordered */
const reorderable = computed(() => !!filters.categoryId && !filters.groupId && !filters.status && !filters.q);
const pageSize = computed(() => (reorderable.value ? 200 : 30));
const reorderable = computed(
() => !!filters.categoryId && !filters.groupId && !filters.status && !filters.q,
);
const requestPageSize = computed(() => (reorderable.value ? 200 : filters.pageSize));
const tablePagination = computed(() => {
if (reorderable.value) return false;
return {
current: filters.page,
pageSize: filters.pageSize,
total: total.value,
showSizeChanger: true,
pageSizeOptions: PAGE_SIZE_OPTIONS,
showQuickJumper: true,
showTotal: (t: number) => `${t.toLocaleString("fa-IR")} محصول`,
onChange: (page: number, size: number) => {
filters.page = page;
if (size !== filters.pageSize) {
filters.pageSize = size;
filters.page = 1;
}
void load();
},
};
});
async function load() {
loading.value = true;
void router.replace({
query: Object.fromEntries(Object.entries(filters).filter(([k, v]) => v && !(k === "page" && v === 1))),
query: Object.fromEntries(
Object.entries({
...filters,
page: filters.page === 1 ? undefined : filters.page,
pageSize: filters.pageSize === DEFAULT_PAGE_SIZE ? undefined : filters.pageSize,
}).filter(([, v]) => v),
),
});
try {
const res = await api<Paginated<AdminProduct>>("/admin/products", {
query: { ...filters, q: filters.q || undefined, pageSize: pageSize.value },
query: {
...filters,
q: filters.q || undefined,
pageSize: requestPageSize.value,
},
});
rows.value = res.items;
total.value = res.total;
@@ -96,7 +147,9 @@ async function togglePublish(row: AdminProduct, isPublished: boolean) {
async function duplicate(row: AdminProduct) {
try {
const copy = await api<{ id: number }>(`/admin/products/${row.id}/duplicate`, { method: "POST" });
const copy = await api<{ id: number }>(`/admin/products/${row.id}/duplicate`, {
method: "POST",
});
void message.success("کپی پیش‌نویس ساخته شد");
await router.push({ name: "product-edit", params: { id: copy.id } });
} catch (e) {
@@ -136,6 +189,11 @@ const columns = computed(() => [
{ title: "", key: "actions", width: 130 },
]);
watch([reorderable, loading, rows], async () => {
await nextTick();
measureTableScroll();
});
onMounted(async () => {
try {
categories.value = await api<AdminCategory[]>("/admin/categories");
@@ -144,123 +202,251 @@ onMounted(async () => {
void message.error(errorMessage(e));
}
await load();
await nextTick();
if (tableShell.value) {
tableResizeObserver = new ResizeObserver(() => measureTableScroll());
tableResizeObserver.observe(tableShell.value);
measureTableScroll();
}
});
onBeforeUnmount(() => {
tableResizeObserver?.disconnect();
tableResizeObserver = null;
});
</script>
<template>
<PageHeader title="محصولات" :subtitle="`${faNumber(total)} محصول`">
<RouterLink :to="{ name: 'product-new', query: filters.categoryId ? { categoryId: filters.categoryId } : {} }">
<a-button type="primary">
<template #icon><PlusOutlined /></template>
محصول جدید
</a-button>
</RouterLink>
</PageHeader>
<AdminPage lock-body>
<template #head>
<div class="toolbar">
<div class="toolbar__start">
<a-popover
v-model:open="filtersOpen"
trigger="click"
placement="bottomLeft"
:overlay-style="{ width: '280px' }"
>
<template #content>
<div class="filter-panel">
<a-select
v-model:value="filters.categoryId"
allow-clear
style="width: 100%"
placeholder="همه دسته‌ها"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
/>
<a-select
v-model:value="filters.groupId"
allow-clear
style="width: 100%"
placeholder="همه زیرگروه‌ها"
:disabled="!groups.length"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
<a-select
v-model:value="filters.status"
allow-clear
style="width: 100%"
placeholder="همه وضعیت‌ها"
:options="[
{ value: 'published', label: 'منتشرشده' },
{ value: 'draft', label: 'پیش‌نویس / مخفی' },
]"
/>
<p v-if="filters.categoryId && !reorderable" class="filter-panel__hint">
برای تغییر ترتیب، فقط فیلتر دسته را نگه دارید.
</p>
</div>
</template>
<a-badge :count="activeFilterCount" :offset="[-4, 4]" color="#ff5a00">
<a-button>
<template #icon><VuesaxIcon name="filter" :size="16" /></template>
فیلتر
</a-button>
</a-badge>
</a-popover>
<a-card class="mb-4" :body-style="{ padding: '16px' }">
<div class="grid gap-3 md:grid-cols-4">
<a-select
v-model:value="filters.categoryId"
allow-clear
placeholder="همه دسته‌ها"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
/>
<a-select
v-model:value="filters.groupId"
allow-clear
placeholder="همه زیرگروه‌ها"
:disabled="!groups.length"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
<a-select
v-model:value="filters.status"
allow-clear
placeholder="همه وضعیت‌ها"
:options="[
{ value: 'published', label: 'منتشرشده' },
{ value: 'draft', label: 'پیش‌نویس / مخفی' },
]"
/>
<a-input-search
v-model:value="filters.q"
allow-clear
placeholder="جستجوی نام یا کد"
@search="() => { filters.page = 1; load(); }"
/>
</div>
<p v-if="filters.categoryId && !reorderable" class="mt-3 text-xs text-gray-400">
برای تغییر ترتیب محصولات، فقط فیلتر دسته را نگه دارید.
</p>
</a-card>
<a-input-search
v-model:value="filters.q"
allow-clear
class="toolbar__search"
placeholder="جستجوی نام یا کد"
@search="
() => {
filters.page = 1;
load();
}
"
/>
</div>
<a-card :body-style="{ padding: 0 }">
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
row-key="id"
:scroll="{ x: 760 }"
:pagination="reorderable ? false : {
current: filters.page,
pageSize,
total,
showSizeChanger: false,
onChange: (p: number) => { filters.page = p; load(); },
}"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'image'">
<template v-if="thumbOf(record as AdminProduct).url">
<img
:src="thumbOf(record as AdminProduct).url!"
alt=""
:title="thumbOf(record as AdminProduct).fallback ? 'این محصول عکس ندارد؛ کاور دسته نمایش داده شده' : undefined"
:class="['size-12 rounded bg-gray-50 object-contain', thumbOf(record as AdminProduct).fallback && 'opacity-40 grayscale']"
<RouterLink
class="toolbar__new"
:to="{
name: 'product-new',
query: filters.categoryId ? { categoryId: filters.categoryId } : {},
}"
>
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</div>
</template>
<div ref="tableShell" class="table-shell">
<a-table
class="table-shell__table"
:columns="columns"
:data-source="rows"
:loading="loading"
row-key="id"
:scroll="{ x: 760, y: scrollY }"
:pagination="tablePagination"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'image'">
<template v-if="thumbOf(record as AdminProduct).url">
<img
:src="thumbOf(record as AdminProduct).url!"
alt=""
:title="
thumbOf(record as AdminProduct).fallback
? 'این محصول عکس ندارد؛ کاور دسته نمایش داده شده'
: undefined
"
:class="[
'size-12 rounded bg-gray-50 object-contain',
thumbOf(record as AdminProduct).fallback && 'opacity-40 grayscale',
]"
/>
</template>
<div v-else class="size-12 rounded bg-gray-100" />
</template>
<template v-else-if="column.key === 'name'">
<RouterLink
:to="{ name: 'product-edit', params: { id: record.id } }"
class="font-bold text-[#0f172a] hover:text-[#ff5a00]"
>
{{ record.name }}
</RouterLink>
<div class="text-xs text-gray-400" dir="ltr">{{ record.code }}</div>
</template>
<template v-else-if="column.key === 'category'">
<div>{{ record.category.name }}</div>
<div v-if="record.group" class="text-xs text-gray-400">{{ record.group.name }}</div>
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.isPublished"
size="small"
@change="(v: unknown) => togglePublish(record as AdminProduct, !!v)"
/>
</template>
<div v-else class="size-12 rounded bg-gray-100" />
<template v-else-if="column.key === 'order'">
<a-space :size="4">
<a-button size="small" :disabled="index === 0" @click="move(index, -1)">
<template #icon><VuesaxIcon name="arrow-up" :size="14" /></template>
</a-button>
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
<template #icon><VuesaxIcon name="arrow-down" :size="14" /></template>
</a-button>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<RouterLink :to="{ name: 'product-edit', params: { id: record.id } }">
<a-button size="small" title="ویرایش"
><template #icon><VuesaxIcon name="edit-2" :size="14" /></template
></a-button>
</RouterLink>
<a-button size="small" title="کپی" @click="duplicate(record as AdminProduct)">
<template #icon><VuesaxIcon name="copy" :size="14" /></template>
</a-button>
<a-popconfirm
title="این محصول حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as AdminProduct)"
>
<a-button size="small" danger title="حذف"
><template #icon><VuesaxIcon name="trash" :size="14" /></template
></a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template v-else-if="column.key === 'name'">
<RouterLink :to="{ name: 'product-edit', params: { id: record.id } }" class="font-bold">
{{ record.name }}
</RouterLink>
<div class="text-xs text-gray-400" dir="ltr">{{ record.code }}</div>
</template>
<template v-else-if="column.key === 'category'">
<div>{{ record.category.name }}</div>
<div v-if="record.group" class="text-xs text-gray-400">{{ record.group.name }}</div>
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.isPublished"
size="small"
@change="(v: unknown) => togglePublish(record as AdminProduct, !!v)"
/>
</template>
<template v-else-if="column.key === 'order'">
<a-space :size="4">
<a-button size="small" :disabled="index === 0" @click="move(index, -1)">
<template #icon><ArrowUpOutlined /></template>
</a-button>
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
<template #icon><ArrowDownOutlined /></template>
</a-button>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<RouterLink :to="{ name: 'product-edit', params: { id: record.id } }">
<a-button size="small" title="ویرایش"><template #icon><EditOutlined /></template></a-button>
</RouterLink>
<a-button size="small" title="کپی" @click="duplicate(record as AdminProduct)">
<template #icon><CopyOutlined /></template>
</a-button>
<a-popconfirm title="این محصول حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record as AdminProduct)">
<a-button size="small" danger title="حذف"><template #icon><DeleteOutlined /></template></a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</a-table>
</div>
</AdminPage>
</template>
<style scoped>
.table-shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.table-shell :deep(.ant-table-wrapper),
.table-shell :deep(.ant-spin-nested-loading),
.table-shell :deep(.ant-spin-container) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.table-shell :deep(.ant-table) {
flex: 1;
min-height: 0;
}
.table-shell :deep(.ant-table-pagination) {
flex-shrink: 0;
margin: 12px 0 0 !important;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.toolbar__start {
display: flex;
flex: 1;
align-items: center;
gap: 16px;
min-width: 0;
}
.toolbar__search {
flex: 1;
min-width: 200px;
max-width: 360px;
}
.toolbar__new {
margin-inline-start: auto;
}
.filter-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.filter-panel__hint {
margin: 0;
font-size: 12px;
color: #94a3b8;
line-height: 1.6;
}
</style>
+1 -1
View File
@@ -37,7 +37,7 @@ async function save() {
<a-card title="مشخصات">
<a-descriptions :column="1">
<a-descriptions-item label="نام">{{ auth.user?.name }}</a-descriptions-item>
<a-descriptions-item label="ایمیل"><span dir="ltr">{{ auth.user?.email }}</span></a-descriptions-item>
<a-descriptions-item label="موبایل"><span dir="ltr">{{ auth.user?.phone }}</span></a-descriptions-item>
<a-descriptions-item label="نقش">{{ auth.user && ROLE_LABELS[auth.user.role] }}</a-descriptions-item>
</a-descriptions>
</a-card>
+2 -2
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { SaveOutlined } from "@ant-design/icons-vue";
import { isSectionKey, SECTION_LABELS, type SectionKey, sectionSchemas } from "@imen/shared";
import { message } from "ant-design-vue";
import { type Component, computed, ref } from "vue";
import { useRoute } from "vue-router";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { SectionResponse } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
@@ -92,7 +92,7 @@ if (valid) void load();
>
<a :href="SITE_URL" target="_blank" rel="noopener"><a-button>مشاهده سایت</a-button></a>
<a-button type="primary" :loading="saving" :disabled="!loaded" @click="save">
<template #icon><SaveOutlined /></template>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</PageHeader>
+16 -10
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons-vue";
import {
type AdminUserDto,
ROLE_LABELS,
@@ -12,6 +11,7 @@ import { message } from "ant-design-vue";
import { computed, onMounted, type Ref, ref } from "vue";
import type { z } from "zod";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import FormField from "@/components/FormField.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
@@ -28,7 +28,7 @@ const saving = ref(false);
// one form model for both create and update; the matching schema validates on save
type Form = z.input<typeof userCreateSchema> & { isActive: boolean };
const empty: Form = { email: "", name: "", role: "EDITOR", password: "", isActive: true };
const empty: Form = { phone: "", name: "", role: "EDITOR", password: "", isActive: true };
const form = useZodForm(userCreateSchema, empty);
const model = form.model as unknown as Ref<Form>;
const isNew = computed(() => editing.value === 0);
@@ -50,7 +50,7 @@ function openNew() {
editing.value = 0;
}
function openEdit(u: AdminUserDto) {
form.reset({ email: u.email, name: u.name, role: u.role, isActive: u.isActive, password: "" } as Form);
form.reset({ phone: u.phone, name: u.name, role: u.role, isActive: u.isActive, password: "" } as Form);
editing.value = u.id;
}
@@ -92,7 +92,7 @@ async function remove(u: AdminUserDto) {
const columns = [
{ title: "نام", dataIndex: "name" },
{ title: "ایمیل", key: "email" },
{ title: "موبایل", key: "phone" },
{ title: "نقش", key: "role", width: 110 },
{ title: "وضعیت", key: "active", width: 100 },
{ title: "آخرین ورود", key: "login", width: 170 },
@@ -105,7 +105,7 @@ onMounted(load);
<template>
<PageHeader title="کاربران پنل" subtitle="مدیر کل به همه بخش‌ها دسترسی دارد؛ ویرایشگر به جز کاربران و تنظیمات سایت">
<a-button type="primary" @click="openNew">
<template #icon><PlusOutlined /></template>
<template #icon><VuesaxIcon name="add" :size="16" /></template>
کاربر جدید
</a-button>
</PageHeader>
@@ -113,7 +113,7 @@ onMounted(load);
<a-card :body-style="{ padding: 0 }">
<a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 700 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'email'"><span dir="ltr">{{ record.email }}</span></template>
<template v-if="column.key === 'phone'"><span dir="ltr">{{ record.phone }}</span></template>
<template v-else-if="column.key === 'role'">
<a-tag :color="record.role === 'ADMIN' ? 'orange' : 'blue'">{{ ROLE_LABELS[record.role as keyof typeof ROLE_LABELS] }}</a-tag>
</template>
@@ -123,7 +123,7 @@ onMounted(load);
<template v-else-if="column.key === 'login'">{{ jalali(record.lastLoginAt) }}</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button size="small" @click="openEdit(record as AdminUserDto)"><template #icon><EditOutlined /></template></a-button>
<a-button size="small" @click="openEdit(record as AdminUserDto)"><template #icon><VuesaxIcon name="edit-2" :size="16" /></template></a-button>
<a-popconfirm
v-if="record.id !== auth.user?.id"
title="این کاربر حذف شود؟"
@@ -131,7 +131,7 @@ onMounted(load);
cancel-text="انصراف"
@confirm="remove(record as AdminUserDto)"
>
<a-button size="small" danger><template #icon><DeleteOutlined /></template></a-button>
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
</a-popconfirm>
</a-space>
</template>
@@ -150,8 +150,14 @@ onMounted(load);
@cancel="editing = null"
>
<a-form layout="vertical" class="mt-4" @submit.prevent="save">
<FormField path="email" label="ایمیل" required>
<a-input v-model:value="model.email" class="ltr-input" :disabled="!isNew" />
<FormField path="phone" label="شماره موبایل" required>
<a-input
v-model:value="model.phone"
class="ltr-input"
:disabled="!isNew"
inputmode="tel"
placeholder="09121234567"
/>
</FormField>
<FormField path="name" label="نام" required>
<a-input v-model:value="model.name" />
+1
View File
@@ -4,6 +4,7 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "ant-design-vue/typings/global"],
"strict": true,
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": ["./src/*"]
+2 -1
View File
@@ -6,7 +6,8 @@ import { defineConfig, type ProxyOptions } from "vite";
// same paths nginx serves in production: /api → NestJS, /media → object storage bucket
const proxy: Record<string, ProxyOptions> = {
"/api": { target: "http://localhost:4000", changeOrigin: true },
"/media": {
// trailing slash: the media library page itself lives at /media
"/media/": {
target: "http://localhost:9000/imen-media",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/media/, ""),
+1 -1
View File
@@ -16,7 +16,7 @@ REFRESH_TTL_DAYS=30
COOKIE_SECURE=false
# first admin user, created by `pnpm db:seed`
ADMIN_EMAIL=admin@imenpishgaman.ir
ADMIN_PHONE=09120000000
ADMIN_PASSWORD=ChangeMe123!
ADMIN_NAME=مدیر سایت
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "applications" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "attributes" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "brand" TEXT NOT NULL DEFAULT '',
ADD COLUMN "details" TEXT NOT NULL DEFAULT '',
ADD COLUMN "features" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "minOrder" TEXT NOT NULL DEFAULT '',
ADD COLUMN "origin" TEXT NOT NULL DEFAULT '',
ADD COLUMN "packaging" TEXT NOT NULL DEFAULT '',
ADD COLUMN "seoDescription" TEXT NOT NULL DEFAULT '',
ADD COLUMN "seoTitle" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sizes" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "standards" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,3 @@
-- Rename login identifier from email to phone
ALTER TABLE "AdminUser" RENAME COLUMN "email" TO "phone";
ALTER INDEX "AdminUser_email_key" RENAME TO "AdminUser_phone_key";
+16 -1
View File
@@ -22,7 +22,7 @@ enum MessageStatus {
model AdminUser {
id Int @id @default(autoincrement())
email String @unique
phone String @unique
name String
passwordHash String
role Role @default(EDITOR)
@@ -114,6 +114,21 @@ model Product {
desc String @default("")
specs String[]
variants String[]
/// long description for the product page; a blank line starts a new paragraph
details String @default("")
brand String @default("")
/// country of manufacture
origin String @default("")
sizes String[] @default([])
standards String[] @default([])
features String[] @default([])
applications String[] @default([])
/// technical specification table: [{ "name": "...", "value": "..." }]
attributes Json @default("[]")
packaging String @default("")
minOrder String @default("")
seoTitle String @default("")
seoDescription String @default("")
imageId Int?
image Media? @relation("ProductImage", fields: [imageId], references: [id], onDelete: SetNull)
gallery ProductImage[]
+7 -7
View File
@@ -15,7 +15,7 @@ const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
export const toUserDto = (u: AdminUser): AdminUserDto => ({
id: u.id,
email: u.email,
phone: u.phone,
name: u.name,
role: u.role,
isActive: u.isActive,
@@ -28,9 +28,9 @@ export interface IssuedTokens extends AuthResponse {
refreshExpiresAt: Date;
}
const INVALID_LOGIN = "ایمیل یا رمز عبور اشتباه است";
const INVALID_LOGIN = "شماره موبایل یا رمز عبور اشتباه است";
/** hash of a random password, verified against when the email is unknown so timing stays the same */
/** hash of a random password, verified against when the phone is unknown so timing stays the same */
const DUMMY_HASH = hashPassword(randomBytes(16).toString("hex"));
@Injectable()
@@ -42,8 +42,8 @@ export class AuthService {
) {}
async login(input: LoginInput, userAgent: string): Promise<IssuedTokens> {
const user = await this.prisma.adminUser.findUnique({ where: { email: input.email } });
// verify even when the user is missing so response time does not reveal valid emails
const user = await this.prisma.adminUser.findUnique({ where: { phone: input.phone } });
// verify even when the user is missing so response time does not reveal valid phones
const ok = await verify(user?.passwordHash ?? (await DUMMY_HASH), input.password).catch(
() => false,
);
@@ -53,7 +53,7 @@ export class AuthService {
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
this.audit.log(user.id, "login", "user", user.id, user.email);
this.audit.log(user.id, "login", "user", user.id, user.phone);
return this.issue(updated, userAgent);
}
@@ -100,7 +100,7 @@ export class AuthService {
where: { id: userId },
data: { passwordHash: await hashPassword(input.newPassword) },
});
this.audit.log(userId, "change-password", "user", userId, user.email);
this.audit.log(userId, "change-password", "user", userId, user.phone);
}
async me(userId: number) {
+12
View File
@@ -127,6 +127,18 @@ export class ProductsService {
desc: src.desc,
specs: src.specs,
variants: src.variants,
details: src.details,
brand: src.brand,
origin: src.origin,
sizes: src.sizes,
standards: src.standards,
features: src.features,
applications: src.applications,
attributes: (src.attributes ?? []) as Prisma.InputJsonValue,
packaging: src.packaging,
minOrder: src.minOrder,
seoTitle: src.seoTitle,
seoDescription: src.seoDescription,
imageId: src.imageId,
sortOrder: src.sortOrder + 1,
isPublished: false,
+2 -2
View File
@@ -22,7 +22,7 @@ export interface JwtPayload {
export interface AuthUser {
id: number;
email: string;
phone: string;
name: string;
role: Role;
}
@@ -54,7 +54,7 @@ export class JwtAuthGuard implements CanActivate {
// re-check the user so deactivation and role changes apply immediately
const user = await this.prisma.adminUser.findUnique({ where: { id: payload.sub } });
if (!user?.isActive) throw new UnauthorizedException("حساب کاربری غیرفعال است");
req.user = { id: user.id, email: user.email, name: user.name, role: user.role };
req.user = { id: user.id, phone: user.phone, name: user.name, role: user.role };
const roles = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [
ctx.getHandler(),
@@ -6,6 +6,7 @@ const FIELD_LABELS: Record<string, string> = {
slug: "اسلاگ",
code: "کد",
email: "ایمیل",
phone: "شماره موبایل",
};
interface UniqueMeta {
+1 -1
View File
@@ -38,7 +38,7 @@ const schema = z.object({
WEB_REVALIDATE_URL: z.string().optional(),
REVALIDATE_SECRET: z.string().optional(),
ADMIN_EMAIL: z.string().optional(),
ADMIN_PHONE: z.string().optional(),
ADMIN_PASSWORD: z.string().optional(),
ADMIN_NAME: z.string().default("مدیر سایت"),
});
+109 -11
View File
@@ -1,15 +1,27 @@
import { Controller, Get, Injectable, Module, NotFoundException, Param } from "@nestjs/common";
import type {
ProductAttribute,
PublicCategory,
PublicCategorySummary,
PublicProduct,
PublicProductDetail,
PublicProductRef,
PublicSite,
} from "@imen/shared";
import type { Media, Product } from "../generated/prisma/client.js";
import { MediaModule } from "../media/media.controller.js";
import { MediaService } from "../media/media.service.js";
import { PrismaService } from "../prisma/prisma.service.js";
import { SectionsModule, SectionsService } from "../sections/sections.module.js";
/** a product is visible on the site only when it and its category are published */
const visible = { isPublished: true, category: { isPublished: true } } as const;
const RELATED_LIMIT = 4;
const isAttribute = (a: unknown): a is ProductAttribute =>
!!a && typeof a === "object" && typeof (a as ProductAttribute).name === "string" && typeof (a as ProductAttribute).value === "string";
/** Read-only data for the website. Only published categories and products are exposed. */
@Injectable()
export class PublicService {
@@ -60,17 +72,7 @@ export class PublicService {
});
if (!c) throw new NotFoundException("دسته‌بندی یافت نشد");
const products: PublicProduct[] = c.products.map((p) => ({
code: p.code,
slug: p.slug,
name: p.name,
nameEn: p.nameEn,
desc: p.desc,
specs: p.specs,
variants: p.variants,
image: p.image && this.media.url(p.image.key),
group: p.group?.slug ?? null,
}));
const products = c.products.map((p) => this.toPublicProduct(p));
// hide groups that have no published products
const usedGroups = new Set(products.map((p) => p.group));
@@ -92,6 +94,92 @@ export class PublicService {
products,
};
}
async product(slug: string): Promise<PublicProductDetail> {
const p = await this.prisma.product.findFirst({
where: { slug, ...visible },
include: {
image: true,
gallery: { orderBy: { sortOrder: "asc" }, include: { media: true } },
group: true,
category: { include: { coverImage: true } },
},
});
if (!p) throw new NotFoundException("محصول یافت نشد");
// main image first, then the gallery, without duplicates
const media = new Map<number, Media>();
for (const m of [p.image, ...p.gallery.map((g) => g.media)]) if (m) media.set(m.id, m);
// related: same subgroup first, then the rest of the category
const siblings = await this.prisma.product.findMany({
where: { categoryId: p.categoryId, isPublished: true, id: { not: p.id } },
orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
include: { image: true, group: { select: { slug: true } } },
});
const sameGroup = (s: { groupId: number | null }) => p.groupId !== null && s.groupId === p.groupId;
const related = [...siblings.filter(sameGroup), ...siblings.filter((s) => !sameGroup(s))]
.slice(0, RELATED_LIMIT)
.map((s) => this.toPublicProduct(s));
return {
...this.toPublicProduct(p),
details: p.details,
brand: p.brand,
origin: p.origin,
sizes: p.sizes,
standards: p.standards,
features: p.features,
applications: p.applications,
attributes: Array.isArray(p.attributes) ? p.attributes.filter(isAttribute) : [],
packaging: p.packaging,
minOrder: p.minOrder,
seoTitle: p.seoTitle,
seoDescription: p.seoDescription,
images: [...media.values()].map((m) => ({
url: this.media.url(m.key),
alt: m.alt || p.name,
width: m.width,
height: m.height,
})),
groupName: p.group?.name ?? null,
category: {
slug: p.category.slug,
code: p.category.code,
name: p.category.name,
nameEn: p.category.nameEn,
fullDesc: p.category.fullDesc,
coverImage: p.category.coverImage && this.media.url(p.category.coverImage.key),
},
related,
updatedAt: p.updatedAt.toISOString(),
};
}
async productRefs(): Promise<PublicProductRef[]> {
const rows = await this.prisma.product.findMany({
where: visible,
orderBy: [{ category: { sortOrder: "asc" } }, { sortOrder: "asc" }],
select: { slug: true, updatedAt: true },
});
return rows.map((r) => ({ slug: r.slug, updatedAt: r.updatedAt.toISOString() }));
}
private toPublicProduct(
p: Product & { image: Media | null; group: { slug: string } | null },
): PublicProduct {
return {
code: p.code,
slug: p.slug,
name: p.name,
nameEn: p.nameEn,
desc: p.desc,
specs: p.specs,
variants: p.variants,
image: p.image && this.media.url(p.image.key),
group: p.group?.slug ?? null,
};
}
}
@Controller("public")
@@ -112,6 +200,16 @@ export class PublicController {
category(@Param("slug") slug: string) {
return this.publicData.category(slug);
}
@Get("products")
products() {
return this.publicData.productRefs();
}
@Get("products/:slug")
product(@Param("slug") slug: string) {
return this.publicData.product(slug);
}
}
@Module({
+27 -7
View File
@@ -10,7 +10,7 @@ import { env } from "../config/env.js";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { SECTION_KEYS, sectionSchemas } from "@imen/shared";
import { normalizeIrMobile, SECTION_KEYS, sectionSchemas } from "@imen/shared";
import { hashPassword } from "../auth/auth.service.js";
import { S3Storage, toWebp } from "../media/s3-storage.js";
import { createPrismaClient } from "../prisma/prisma.service.js";
@@ -133,21 +133,41 @@ async function seedCatalog() {
}
async function seedAdmin() {
if (!env.ADMIN_EMAIL || !env.ADMIN_PASSWORD) {
console.log(" ADMIN_EMAIL / ADMIN_PASSWORD not set — skipping admin user");
if (!env.ADMIN_PHONE || !env.ADMIN_PASSWORD) {
console.log(" ADMIN_PHONE / ADMIN_PASSWORD not set — skipping admin user");
return;
}
const email = env.ADMIN_EMAIL.trim().toLowerCase();
if (await prisma.adminUser.findUnique({ where: { email } })) return;
const phone = normalizeIrMobile(env.ADMIN_PHONE);
if (!/^09\d{9}$/.test(phone)) {
console.log(` ADMIN_PHONE invalid (${env.ADMIN_PHONE}) — skipping admin user`);
return;
}
const existing = await prisma.adminUser.findUnique({ where: { phone } });
if (existing) return;
// After email→phone migration, upgrade the first admin whose phone still looks like an email
const legacy = await prisma.adminUser.findFirst({
where: { role: "ADMIN", phone: { contains: "@" } },
orderBy: { id: "asc" },
});
if (legacy) {
await prisma.adminUser.update({
where: { id: legacy.id },
data: { phone, name: env.ADMIN_NAME, passwordHash: await hashPassword(env.ADMIN_PASSWORD) },
});
console.log(` admin user migrated to ${phone}`);
return;
}
await prisma.adminUser.create({
data: {
email,
phone,
name: env.ADMIN_NAME,
role: "ADMIN",
passwordHash: await hashPassword(env.ADMIN_PASSWORD),
},
});
console.log(` admin user ${email}`);
console.log(` admin user ${phone}`);
}
async function main() {
+4 -4
View File
@@ -40,13 +40,13 @@ export class UsersService {
async create(input: UserCreateInput, actor: AuthUser) {
const user = await this.prisma.adminUser.create({
data: {
email: input.email,
phone: input.phone,
name: input.name,
role: input.role,
passwordHash: await hashPassword(input.password),
},
});
this.audit.log(actor.id, "create", "user", user.id, user.email);
this.audit.log(actor.id, "create", "user", user.id, user.phone);
return toUserDto(user);
}
@@ -65,14 +65,14 @@ export class UsersService {
});
// a password reset or deactivation ends the user's existing sessions
if (input.password || !input.isActive) await this.auth.revokeAll(id);
this.audit.log(actor.id, "update", "user", id, user.email);
this.audit.log(actor.id, "update", "user", id, user.phone);
return toUserDto(user);
}
async remove(id: number, actor: AuthUser) {
if (id === actor.id) throw new BadRequestException("نمی‌توانید حساب خودتان را حذف کنید");
const user = await this.prisma.adminUser.delete({ where: { id } });
this.audit.log(actor.id, "delete", "user", id, user.email);
this.audit.log(actor.id, "delete", "user", id, user.phone);
}
}
+19 -8
View File
@@ -15,12 +15,12 @@ import { PrismaService } from "../src/prisma/prisma.service.js";
describe("API (e2e)", () => {
let app: INestApplication;
let prisma: PrismaService;
const suffix = randomBytes(4).toString("hex");
const admin = { email: `e2e-admin-${suffix}@test.local`, password: "e2e-Password-1" };
const editor = { email: `e2e-editor-${suffix}@test.local`, password: "e2e-Password-2" };
const suffix = String(randomBytes(4).readUInt32BE(0) % 10_000_000).padStart(7, "0");
const admin = { phone: `0912${suffix}`, password: "e2e-Password-1" };
const editor = { phone: `0935${suffix}`, password: "e2e-Password-2" };
const http = () => request(app.getHttpServer());
const login = async (u: { email: string; password: string }) => {
const login = async (u: { phone: string; password: string }) => {
const res = await http().post("/api/admin/auth/login").send(u).expect(200);
return { token: res.body.accessToken as string, cookie: res.headers["set-cookie"] as unknown as string[] };
};
@@ -35,14 +35,14 @@ describe("API (e2e)", () => {
prisma = app.get(PrismaService);
await prisma.adminUser.createMany({
data: [
{ email: admin.email, name: "e2e admin", role: "ADMIN", passwordHash: await hashPassword(admin.password) },
{ email: editor.email, name: "e2e editor", role: "EDITOR", passwordHash: await hashPassword(editor.password) },
{ phone: admin.phone, name: "e2e admin", role: "ADMIN", passwordHash: await hashPassword(admin.password) },
{ phone: editor.phone, name: "e2e editor", role: "EDITOR", passwordHash: await hashPassword(editor.password) },
],
});
});
afterAll(async () => {
await prisma.adminUser.deleteMany({ where: { email: { in: [admin.email, editor.email] } } });
await prisma.adminUser.deleteMany({ where: { phone: { in: [admin.phone, editor.phone] } } });
await app.close();
});
@@ -63,6 +63,17 @@ describe("API (e2e)", () => {
await http().get("/api/public/categories/no-such-category").expect(404);
});
it("returns a product page with related products, 404 for unknown slugs", async () => {
const { body: refs } = await http().get("/api/public/products").expect(200);
expect(refs.length).toBeGreaterThan(0);
const res = await http().get(`/api/public/products/${refs[0].slug}`).expect(200);
expect(res.body.category.slug).toBeTruthy();
expect(Array.isArray(res.body.attributes)).toBe(true);
expect(res.body.related.length).toBeLessThanOrEqual(4);
expect(res.body.related.some((r: { slug: string }) => r.slug === refs[0].slug)).toBe(false);
await http().get("/api/public/products/no-such-product").expect(404);
});
it("validates the contact form", async () => {
const res = await http().post("/api/public/contact").send({ name: "", org: "x", phone: "abc" }).expect(400);
expect(res.body.issues.map((i: { path: string }) => i.path).sort()).toEqual(["name", "phone"]);
@@ -81,7 +92,7 @@ describe("API (e2e)", () => {
describe("auth", () => {
it("rejects admin routes without a token and wrong passwords", async () => {
await http().get("/api/admin/dashboard").expect(401);
await http().post("/api/admin/auth/login").send({ email: admin.email, password: "wrong-password" }).expect(401);
await http().post("/api/admin/auth/login").send({ phone: admin.phone, password: "wrong-password" }).expect(401);
});
it("sets an httpOnly refresh cookie and rotates it", async () => {
+3 -1
View File
@@ -17,7 +17,9 @@ useJsonLd([organizationSchema(seo), websiteSchema(seo)]);
<template>
<StyleProvider :cache="$antdCache">
<ConfigProvider :theme="antdTheme" direction="rtl" :locale="faIR">
<NuxtPage />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</ConfigProvider>
</StyleProvider>
</template>
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { AnimatePresence } from "motion-v";
import Preloader from "./Preloader.vue";
import Cursor from "./Cursor.vue";
import Header from "./Header.vue";
import BackToTop from "./BackToTop.vue";
const ui = useUiStore();
</script>
<template>
<AnimatePresence>
<Preloader v-if="!ui.ready" @complete="ui.setReady" />
</AnimatePresence>
<Cursor />
<div class="noise" aria-hidden="true" />
<Header />
<slot />
<BackToTop />
</template>
+6 -3
View File
@@ -12,8 +12,10 @@ withDefaults(
desc?: string;
center?: boolean;
headingId?: string;
/** heading level; 1 for the main title of a page (one <h1> per page) */
level?: 1 | 2;
}>(),
{ center: false },
{ center: false, level: 2 },
);
</script>
@@ -29,7 +31,8 @@ withDefaults(
</div>
</Reveal>
<Reveal :delay="0.08">
<h2
<component
:is="`h${level}`"
:id="headingId"
class="mt-5 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]"
>
@@ -40,7 +43,7 @@ withDefaults(
<span class="text-accent">{{ highlight }}</span>
</template>
</slot>
</h2>
</component>
</Reveal>
<Reveal v-if="desc" :delay="0.16">
<p :class="`mt-5 max-w-xl leading-8 text-muted ${center ? 'mx-auto' : ''}`">
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { AnimatePresence } from "motion-v";
import BackToTop from "~/components/layout/BackToTop.vue";
import Cursor from "~/components/layout/Cursor.vue";
import Footer from "~/components/layout/Footer.vue";
import Header from "~/components/layout/Header.vue";
import Preloader from "~/components/layout/Preloader.vue";
/*
* Site chrome shared by every page. It lives in the layout (not in each page) so navigation
* swaps only the page content: animations here — e.g. the preloader's exit — never run on a
* page that is being torn down, which crashed Vue's transition code.
*/
const ui = useUiStore();
</script>
<template>
<div>
<AnimatePresence>
<Preloader v-if="!ui.ready" @complete="ui.setReady" />
</AnimatePresence>
<Cursor />
<div class="noise" aria-hidden="true" />
<Header />
<slot />
<Footer />
<BackToTop />
</div>
</template>
+178 -176
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import type { PublicCategory } from "@imen/shared";
import { ArrowRight, Phone } from "lucide-vue-next";
import { ArrowLeft, ArrowRight, Phone } from "lucide-vue-next";
import { categoryPageSchema } from "#shared/seo/schema";
import AppShell from "~/components/layout/AppShell.vue";
import Footer from "~/components/layout/Footer.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
import Reveal from "~/components/ui/Reveal.vue";
@@ -56,190 +54,194 @@ const groupCount = (g: string) => category.products.filter((p) => p.group === g)
</script>
<template>
<AppShell>
<main>
<!-- ---- hero banner ---- -->
<section class="relative overflow-hidden pt-32 pb-20 md:pt-40 md:pb-28">
<div class="absolute inset-0">
<img
v-if="category.coverImage"
:src="category.coverImage"
:alt="category.name"
class="absolute inset-0 size-full object-cover"
fetchpriority="high"
decoding="async"
/>
<div class="absolute inset-0 bg-gradient-to-l from-bg via-bg/85 to-bg/40" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 to-bg/30" />
<div class="absolute inset-0 bg-grid opacity-40" />
<main>
<!-- ---- hero banner ---- -->
<section class="relative overflow-hidden pt-32 pb-20 md:pt-40 md:pb-28">
<div class="absolute inset-0">
<img
v-if="category.coverImage"
:src="category.coverImage"
:alt="category.name"
class="absolute inset-0 size-full object-cover"
fetchpriority="high"
decoding="async"
/>
<div class="absolute inset-0 bg-gradient-to-l from-bg via-bg/85 to-bg/40" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 to-bg/30" />
<div class="absolute inset-0 bg-grid opacity-40" />
</div>
<div class="tape-v absolute bottom-0 end-0 z-10 h-32 w-1.5 opacity-70" />
<div class="relative z-10 mx-auto max-w-[1480px] px-5 md:px-8">
<Reveal>
<nav class="flex items-center gap-2 text-sm text-muted" aria-label="مسیر">
<NuxtLink to="/" class="transition-colors hover:text-accent">خانه</NuxtLink>
<span class="text-line">/</span>
<NuxtLink to="/#products" class="transition-colors hover:text-accent">
محصولات
</NuxtLink>
<span class="text-line">/</span>
<span class="text-ink">{{ category.name }}</span>
</nav>
</Reveal>
<div class="mt-8">
<SectionHead
:tag="`${faNum(String(categoryIndex + 1).padStart(2, '0'))} — محصولات`"
:desc="category.fullDesc"
:level="1"
>
<template #title>{{ category.name }}</template>
</SectionHead>
</div>
<div class="tape-v absolute bottom-0 end-0 z-10 h-32 w-1.5 opacity-70" />
<div class="relative z-10 mx-auto max-w-[1480px] px-5 md:px-8">
<Reveal>
<nav class="flex items-center gap-2 text-sm text-muted" aria-label="مسیر">
<NuxtLink to="/" class="transition-colors hover:text-accent">خانه</NuxtLink>
<span class="text-line">/</span>
<NuxtLink to="/#products" class="transition-colors hover:text-accent">
محصولات
</NuxtLink>
<span class="text-line">/</span>
<span class="text-ink">{{ category.name }}</span>
</nav>
</Reveal>
<div class="mt-8">
<SectionHead
:tag="`${faNum(String(categoryIndex + 1).padStart(2, '0'))} — محصولات`"
:desc="category.fullDesc"
<Reveal :delay="0.2">
<div class="mt-8 flex flex-wrap items-center gap-4">
<span
class="rounded-full border border-accent/40 bg-accent/10 px-4 py-1.5 font-mono text-xs text-accent backdrop-blur"
>
<template #title>{{ category.name }}</template>
</SectionHead>
{{ faNum(category.products.length) }} محصول
</span>
<span class="font-mono text-xs tracking-[0.25em] text-muted/60">
{{ category.nameEn }}
</span>
</div>
</Reveal>
</div>
</section>
<Reveal :delay="0.2">
<div class="mt-8 flex flex-wrap items-center gap-4">
<span
class="rounded-full border border-accent/40 bg-accent/10 px-4 py-1.5 font-mono text-xs text-accent backdrop-blur"
>
{{ faNum(category.products.length) }} محصول
</span>
<span class="font-mono text-xs tracking-[0.25em] text-muted/60">
{{ category.nameEn }}
</span>
</div>
</Reveal>
<!-- ---- products grid ---- -->
<section class="relative py-16 md:py-24">
<div class="absolute inset-0 bg-grid-fine opacity-30" />
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<div v-if="category.groups?.length" class="mb-10">
<div class="flex flex-wrap gap-2" role="tablist" aria-label="فیلتر زیردسته">
<button
type="button"
role="tab"
:aria-selected="activeGroup === null"
class="rounded-full border px-4 py-2 text-sm transition-colors"
:class="
activeGroup === null
? 'border-accent bg-accent text-bg font-bold'
: 'border-line text-muted hover:border-accent/60 hover:text-accent'
"
@click="activeGroup = null"
>
همه
<span class="ms-1 font-mono text-xs opacity-70">{{ faNum(category.products.length) }}</span>
</button>
<button
v-for="g in category.groups"
:key="g.slug"
type="button"
role="tab"
:aria-selected="activeGroup === g.slug"
class="rounded-full border px-4 py-2 text-sm transition-colors"
:class="
activeGroup === g.slug
? 'border-accent bg-accent text-bg font-bold'
: 'border-line text-muted hover:border-accent/60 hover:text-accent'
"
@click="activeGroup = g.slug"
>
{{ g.name }}
<span class="ms-1 font-mono text-xs opacity-70">{{ faNum(groupCount(g.slug)) }}</span>
</button>
</div>
<p v-if="activeGroupDesc" class="mt-4 text-sm text-muted">{{ activeGroupDesc }}</p>
</div>
</section>
<!-- ---- products grid ---- -->
<section class="relative py-16 md:py-24">
<div class="absolute inset-0 bg-grid-fine opacity-30" />
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<div v-if="category.groups?.length" class="mb-10">
<div class="flex flex-wrap gap-2" role="tablist" aria-label="فیلتر زیردسته">
<button
type="button"
role="tab"
:aria-selected="activeGroup === null"
class="rounded-full border px-4 py-2 text-sm transition-colors"
:class="
activeGroup === null
? 'border-accent bg-accent text-bg font-bold'
: 'border-line text-muted hover:border-accent/60 hover:text-accent'
"
@click="activeGroup = null"
>
همه
<span class="ms-1 font-mono text-xs opacity-70">{{ faNum(category.products.length) }}</span>
</button>
<button
v-for="g in category.groups"
:key="g.slug"
type="button"
role="tab"
:aria-selected="activeGroup === g.slug"
class="rounded-full border px-4 py-2 text-sm transition-colors"
:class="
activeGroup === g.slug
? 'border-accent bg-accent text-bg font-bold'
: 'border-line text-muted hover:border-accent/60 hover:text-accent'
"
@click="activeGroup = g.slug"
>
{{ g.name }}
<span class="ms-1 font-mono text-xs opacity-70">{{ faNum(groupCount(g.slug)) }}</span>
</button>
</div>
<p v-if="activeGroupDesc" class="mt-4 text-sm text-muted">{{ activeGroupDesc }}</p>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Reveal v-for="(product, i) in visibleProducts" :key="product.code" :delay="i * 0.06">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Reveal v-for="(product, i) in visibleProducts" :key="product.code" :delay="i * 0.06">
<NuxtLink
:to="`/products/${product.slug}`"
class="group flex h-full flex-col overflow-hidden rounded-xl border border-line bg-panel/80 backdrop-blur transition-all duration-500 hover:border-accent/40 hover:shadow-[0_8px_40px_rgba(255,90,0,0.08)]"
>
<div
class="group h-full overflow-hidden rounded-xl border border-line bg-panel/80 backdrop-blur transition-all duration-500 hover:border-accent/40 hover:shadow-[0_8px_40px_rgba(255,90,0,0.08)]"
class="relative aspect-[4/3] overflow-hidden"
:class="product.image && 'bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.14),transparent_70%)]'"
>
<div
class="relative aspect-[4/3] overflow-hidden"
:class="product.image && 'bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.14),transparent_70%)]'"
>
<img
:src="product.image ?? category.coverImage ?? undefined"
:alt="product.name"
loading="lazy"
decoding="async"
class="size-full transition-transform duration-700 group-hover:scale-105"
:class="product.image ? 'object-contain p-8' : 'object-cover'"
/>
<div v-if="!product.image" class="absolute inset-0 bg-gradient-to-t from-bg/60 to-transparent" />
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-4">
<span class="rounded-full bg-bg/60 px-3 py-1 font-mono text-[10px] tracking-widest text-ink/70 backdrop-blur">
{{ product.code }}
</span>
<span class="font-mono text-xs text-accent">
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
</div>
</div>
<div class="p-5 md:p-6">
<h3 class="text-xl font-black">{{ product.name }}</h3>
<p class="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted">
{{ product.nameEn }}
</p>
<p class="mt-3 text-sm leading-8 text-muted">{{ product.desc }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<span
v-for="s in product.specs"
:key="s"
class="rounded-full border border-accent/40 bg-accent/10 px-3 py-1 font-mono text-[10px] text-accent"
>
{{ s }}
</span>
</div>
<div v-if="product.variants?.length" class="mt-4">
<div class="mb-2 text-xs text-muted">مدل‌ها / رنگ‌ها:</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="v in product.variants"
:key="v"
class="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-[11px] text-ink/70"
>
{{ v }}
</span>
</div>
</div>
<img
:src="product.image ?? category.coverImage ?? undefined"
:alt="product.name"
loading="lazy"
decoding="async"
class="size-full transition-transform duration-700 group-hover:scale-105"
:class="product.image ? 'object-contain p-8' : 'object-cover'"
/>
<div v-if="!product.image" class="absolute inset-0 bg-gradient-to-t from-bg/60 to-transparent" />
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-4">
<span class="rounded-full bg-bg/60 px-3 py-1 font-mono text-[10px] tracking-widest text-ink/70 backdrop-blur">
{{ product.code }}
</span>
<span class="font-mono text-xs text-accent">
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
</div>
</div>
</Reveal>
</div>
</div>
</section>
<!-- ---- bottom CTA ---- -->
<section class="border-t border-line py-12 md:py-16">
<div class="mx-auto flex max-w-[1480px] flex-wrap items-center justify-between gap-6 px-5 md:px-8">
<NuxtLink
to="/#products"
class="group inline-flex items-center gap-2 text-muted transition-colors hover:text-accent"
>
<ArrowRight class="size-4 transition-transform group-hover:translate-x-1" />
بازگشت به دسته‌بندی‌ها
</NuxtLink>
<a
href="/#contact"
class="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-bold text-bg transition-all hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
>
<Phone class="size-4" />
درخواست قیمت
</a>
<div class="flex flex-1 flex-col p-5 md:p-6">
<h3 class="text-xl font-black transition-colors group-hover:text-accent">{{ product.name }}</h3>
<p class="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted">
{{ product.nameEn }}
</p>
<p class="mt-3 text-sm leading-8 text-muted">{{ product.desc }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<span
v-for="s in product.specs"
:key="s"
class="rounded-full border border-accent/40 bg-accent/10 px-3 py-1 font-mono text-[10px] text-accent"
>
{{ s }}
</span>
</div>
<div v-if="product.variants?.length" class="mt-4">
<div class="mb-2 text-xs text-muted">مدل‌ها / رنگ‌ها:</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="v in product.variants"
:key="v"
class="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-[11px] text-ink/70"
>
{{ v }}
</span>
</div>
</div>
<span class="mt-auto inline-flex items-center gap-2 pt-5 text-sm font-bold text-accent">
مشاهده جزئیات
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-1" />
</span>
</div>
</NuxtLink>
</Reveal>
</div>
</section>
</main>
<Footer />
</AppShell>
</div>
</section>
<!-- ---- bottom CTA ---- -->
<section class="border-t border-line py-12 md:py-16">
<div class="mx-auto flex max-w-[1480px] flex-wrap items-center justify-between gap-6 px-5 md:px-8">
<NuxtLink
to="/#products"
class="group inline-flex items-center gap-2 text-muted transition-colors hover:text-accent"
>
<ArrowRight class="size-4 transition-transform group-hover:translate-x-1" />
بازگشت به دسته‌بندی‌ها
</NuxtLink>
<a
href="/#contact"
class="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-bold text-bg transition-all hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
>
<Phone class="size-4" />
درخواست قیمت
</a>
</div>
</section>
</main>
</template>
+12 -17
View File
@@ -1,6 +1,4 @@
<script setup lang="ts">
import AppShell from "~/components/layout/AppShell.vue";
import Footer from "~/components/layout/Footer.vue";
import Hero from "~/components/sections/Hero.vue";
import TapeMarquee from "~/components/sections/TapeMarquee.vue";
import Stats from "~/components/sections/Stats.vue";
@@ -28,19 +26,16 @@ useSeoMeta({
</script>
<template>
<AppShell>
<main>
<Hero />
<TapeMarquee />
<Stats />
<Products />
<About />
<Services />
<Standards />
<Industries />
<Testimonials />
<Contact />
</main>
<Footer />
</AppShell>
<main>
<Hero />
<TapeMarquee />
<Stats />
<Products />
<About />
<Services />
<Standards />
<Industries />
<Testimonials />
<Contact />
</main>
</template>
+627
View File
@@ -0,0 +1,627 @@
<script setup lang="ts">
import type { PublicProductDetail, PublicProductImage } from "@imen/shared";
import { useEventListener } from "@vueuse/core";
import {
ArrowLeft,
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
Expand,
Headset,
Phone,
X,
} from "lucide-vue-next";
import { breadcrumbSchema, productSchema } from "#shared/seo/schema";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const route = useRoute();
const slug = route.params.slug as string;
const { $api } = useNuxtApp();
const site = useSiteStore();
const contact = useContactStore();
const { data, error } = await useAsyncData(`product:${slug}`, () =>
$api<PublicProductDetail>(`/public/products/${encodeURIComponent(slug)}`),
);
if (error.value || !data.value) {
const notFound = error.value?.statusCode === 404;
throw createError({
statusCode: notFound ? 404 : 500,
statusMessage: notFound ? "محصول یافت نشد" : "خطا در دریافت اطلاعات",
fatal: true,
});
}
const product = data.value;
const categoryPath = `/categories/${product.category.slug}`;
/* ---------------- gallery ---------------- */
// products without their own photos show the category cover instead
const ownImages = product.images.length > 0;
const images: PublicProductImage[] = ownImages
? product.images
: product.category.coverImage
? [{ url: product.category.coverImage, alt: product.name, width: null, height: null }]
: [];
const active = ref(0);
const current = computed(() => images[active.value]);
const go = (step: number) => {
active.value = (active.value + step + images.length) % images.length;
};
const lightbox = ref(false);
watch(lightbox, (open) => {
document.body.style.overflow = open ? "hidden" : "";
});
onBeforeUnmount(() => {
document.body.style.overflow = "";
});
if (import.meta.client) {
useEventListener(window, "keydown", (e: KeyboardEvent) => {
if (!lightbox.value) return;
if (e.key === "Escape") lightbox.value = false;
// RTL: the left arrow moves forward
if (e.key === "ArrowLeft") go(1);
if (e.key === "ArrowRight") go(-1);
});
}
/* ---------------- content ---------------- */
/** a blank line in the admin text starts a new paragraph */
const paragraphs = product.details
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const facts = [
{ label: "برند", value: product.brand },
{ label: "کشور سازنده", value: product.origin },
{ label: "بسته‌بندی", value: product.packaging },
{ label: "حداقل سفارش", value: product.minOrder },
].filter((f) => f.value);
const specRows = [
{ name: "کد محصول", value: product.code, ltr: true },
{ name: "دسته", value: product.category.name },
...(product.groupName ? [{ name: "زیرگروه", value: product.groupName }] : []),
...(product.brand ? [{ name: "برند", value: product.brand }] : []),
...(product.origin ? [{ name: "کشور سازنده", value: product.origin }] : []),
...product.attributes,
...(product.standards.length ? [{ name: "استانداردها", value: product.standards.join("، "), ltr: true }] : []),
...(product.sizes.length ? [{ name: "سایزها", value: product.sizes.join("، ") }] : []),
...(product.variants.length ? [{ name: "مدل‌ها / رنگ‌ها", value: product.variants.join("، ") }] : []),
...(product.packaging ? [{ name: "بسته‌بندی", value: product.packaging }] : []),
...(product.minOrder ? [{ name: "حداقل سفارش", value: product.minOrder }] : []),
] as { name: string; value: string; ltr?: boolean }[];
const trust = computed(() => site.sections.productsIntro.trust);
const settings = computed(() => site.settings);
/** fills the contact form on the home page with this product, then scrolls to it */
const requestQuote = () =>
contact.prefill(`درخواست قیمت و موجودی:\n${product.name} — کد ${product.code}`);
/* ---------------- SEO ---------------- */
const absUrl = useAbsUrl();
const canonical = absUrl(`/products/${slug}`);
const title = product.seoTitle || product.name;
const description = (
product.seoDescription ||
product.desc ||
paragraphs[0] ||
product.category.fullDesc
).slice(0, 300);
// the site-wide titleTemplate appends the brand name
useHead({
title,
link: [
{ rel: "canonical", href: canonical },
...(images[0] ? [{ rel: "preload" as const, as: "image" as const, href: images[0].url }] : []),
],
});
useSeoMeta({
description,
robots: "index, follow",
ogUrl: canonical,
ogTitle: `${title} | ${site.settings.name}`,
ogDescription: description,
...(images[0] && {
ogImage: absUrl(images[0].url),
ogImageAlt: images[0].alt,
twitterImage: absUrl(images[0].url),
}),
});
const seo = useSeoContext();
useJsonLd(
[
productSchema(seo, product),
breadcrumbSchema(seo, [
{ name: "خانه", path: "/" },
{ name: product.category.name, path: categoryPath },
{ name: product.name, path: `/products/${slug}` },
]),
],
`product-${slug}`,
);
</script>
<template>
<main>
<!-- ---- product hero: gallery + summary ---- -->
<section class="relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-24">
<div class="absolute inset-0 bg-grid opacity-30" />
<div class="tape-v absolute bottom-0 end-0 z-10 h-32 w-1.5 opacity-70" />
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<Reveal>
<nav class="flex flex-wrap items-center gap-2 text-sm text-muted" aria-label="مسیر">
<NuxtLink to="/" class="transition-colors hover:text-accent">خانه</NuxtLink>
<span class="text-line">/</span>
<NuxtLink to="/#products" class="transition-colors hover:text-accent">محصولات</NuxtLink>
<span class="text-line">/</span>
<NuxtLink :to="categoryPath" class="transition-colors hover:text-accent">
{{ product.category.name }}
</NuxtLink>
<span class="text-line">/</span>
<span class="text-ink">{{ product.name }}</span>
</nav>
</Reveal>
<div class="mt-8 grid gap-10 lg:grid-cols-12 lg:gap-14">
<!-- gallery -->
<Reveal class="lg:col-span-6" :delay="0.08">
<div class="relative">
<div class="hazard absolute -top-3 -end-3 z-10 h-6 w-28 rounded-full opacity-90" />
<div class="relative overflow-hidden rounded-2xl border border-line bg-panel/80">
<div
class="group relative aspect-square overflow-hidden"
:class="ownImages && 'bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.16),transparent_70%)]'"
>
<Transition
enter-active-class="transition duration-500 ease-out"
enter-from-class="opacity-0 scale-[0.97]"
leave-active-class="absolute inset-0 transition duration-200"
leave-to-class="opacity-0"
>
<img
v-if="current"
:key="current.url"
:src="current.url"
:alt="current.alt"
:width="current.width ?? undefined"
:height="current.height ?? undefined"
fetchpriority="high"
decoding="async"
class="size-full"
:class="ownImages ? 'object-contain p-10 md:p-16' : 'object-cover'"
/>
</Transition>
<div v-if="!ownImages" class="absolute inset-0 bg-gradient-to-t from-bg/70 to-transparent" />
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-4 md:p-5">
<span class="rounded-full bg-bg/60 px-3 py-1 font-mono text-[10px] tracking-widest text-ink/70 backdrop-blur">
{{ product.code }}
</span>
<span v-if="images.length > 1" class="font-mono text-xs text-accent">
{{ faNum(String(active + 1).padStart(2, "0")) }} / {{ faNum(String(images.length).padStart(2, "0")) }}
</span>
</div>
<button
v-if="current"
type="button"
aria-label="نمایش بزرگ تصویر"
class="absolute bottom-4 start-4 flex size-11 items-center justify-center rounded-full border border-line bg-bg/70 text-ink/80 backdrop-blur transition-colors hover:border-accent hover:text-accent"
@click="lightbox = true"
>
<Expand class="size-4" />
</button>
<template v-if="images.length > 1">
<button
type="button"
aria-label="تصویر قبلی"
class="absolute start-4 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-full border border-line bg-bg/70 text-ink/80 opacity-0 backdrop-blur transition-all hover:border-accent hover:text-accent group-hover:opacity-100 max-md:opacity-100"
@click="go(-1)"
>
<ChevronRight class="size-5" />
</button>
<button
type="button"
aria-label="تصویر بعدی"
class="absolute end-4 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-full border border-line bg-bg/70 text-ink/80 opacity-0 backdrop-blur transition-all hover:border-accent hover:text-accent group-hover:opacity-100 max-md:opacity-100"
@click="go(1)"
>
<ChevronLeft class="size-5" />
</button>
</template>
</div>
</div>
<div v-if="images.length > 1" class="mt-4 flex gap-3 overflow-x-auto pb-1">
<button
v-for="(img, i) in images"
:key="img.url"
type="button"
:aria-label="`تصویر ${faNum(i + 1)}`"
:aria-current="i === active"
class="size-20 shrink-0 overflow-hidden rounded-xl border bg-panel/80 transition-all md:size-24"
:class="i === active ? 'border-accent shadow-[0_0_24px_rgba(255,90,0,0.25)]' : 'border-line opacity-60 hover:opacity-100'"
@click="active = i"
>
<img :src="img.url" :alt="img.alt" loading="lazy" class="size-full object-contain p-2" />
</button>
</div>
</div>
</Reveal>
<!-- summary -->
<div class="lg:col-span-6">
<Reveal :delay="0.12">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border border-accent/40 bg-accent/10 px-3 py-1 font-mono text-xs text-accent">
{{ product.code }}
</span>
<NuxtLink
:to="categoryPath"
class="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-accent/60 hover:text-accent"
>
{{ product.category.name }}
</NuxtLink>
<span v-if="product.groupName" class="rounded-full border border-line px-3 py-1 text-xs text-muted">
{{ product.groupName }}
</span>
</div>
<h1 class="mt-6 text-3xl font-black leading-[1.3] md:text-5xl md:leading-[1.25]">
{{ product.name }}
</h1>
<p v-if="product.nameEn" class="mt-3 font-mono text-xs tracking-[0.25em] text-muted">
{{ product.nameEn }}
</p>
<p v-if="product.desc" class="mt-6 max-w-2xl leading-9 text-ink/75">{{ product.desc }}</p>
<div v-if="product.specs.length" class="mt-6 flex flex-wrap gap-2">
<span
v-for="s in product.specs"
:key="s"
class="rounded-full border border-accent/40 bg-accent/10 px-3 py-1.5 font-mono text-[11px] text-accent"
>
{{ s }}
</span>
</div>
</Reveal>
<Reveal v-if="facts.length || product.standards.length" :delay="0.18">
<dl class="mt-8 grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-2">
<div v-for="f in facts" :key="f.label" class="bg-panel/80 p-4">
<dt class="text-xs text-muted">{{ f.label }}</dt>
<dd class="mt-1.5 font-bold">{{ f.value }}</dd>
</div>
<div v-if="product.standards.length" class="bg-panel/80 p-4 sm:col-span-2">
<dt class="text-xs text-muted">استانداردها و گواهی‌ها</dt>
<dd class="mt-2 flex flex-wrap gap-2">
<span
v-for="s in product.standards"
:key="s"
class="rounded-md border border-ink/15 px-2.5 py-1 font-mono text-xs tracking-wider text-ink/85"
dir="ltr"
>
{{ s }}
</span>
</dd>
</div>
</dl>
</Reveal>
<Reveal v-if="product.variants.length || product.sizes.length" :delay="0.22">
<div class="mt-8 space-y-5">
<div v-if="product.variants.length">
<div class="mb-2.5 text-xs text-muted">مدل‌ها / رنگ‌ها</div>
<div class="flex flex-wrap gap-2">
<span
v-for="v in product.variants"
:key="v"
class="rounded-lg border border-line bg-panel-2 px-3 py-1.5 text-sm text-ink/80"
>
{{ v }}
</span>
</div>
</div>
<div v-if="product.sizes.length">
<div class="mb-2.5 text-xs text-muted">سایزها</div>
<div class="flex flex-wrap gap-2">
<span
v-for="s in product.sizes"
:key="s"
class="min-w-11 rounded-lg border border-line bg-panel-2 px-3 py-1.5 text-center font-mono text-sm text-ink/80"
>
{{ s }}
</span>
</div>
</div>
</div>
</Reveal>
<Reveal :delay="0.26">
<div class="mt-9 flex flex-wrap items-center gap-4">
<NuxtLink
to="/#contact"
class="group inline-flex items-center gap-2.5 rounded-full bg-accent px-7 py-4 font-bold text-bg transition-all duration-300 hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
@click="requestQuote"
>
درخواست قیمت و موجودی
<ArrowLeft class="size-5 transition-transform duration-300 group-hover:-translate-x-1.5" />
</NuxtLink>
<a
:href="`tel:${settings.phone}`"
class="inline-flex items-center gap-2.5 rounded-full border border-ink/25 px-6 py-4 font-bold text-ink transition-colors duration-300 hover:border-accent hover:text-accent"
>
<Phone class="size-4" />
<span class="font-mono tracking-wider" dir="ltr">{{ settings.phoneDisplay }}</span>
</a>
</div>
</Reveal>
<Reveal v-if="trust.length" :delay="0.3">
<ul class="mt-9 grid gap-4 border-t border-line pt-7 sm:grid-cols-3">
<li v-for="t in trust" :key="t.title" class="flex items-start gap-3">
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent">
<DynamicIcon :name="t.icon" class="size-4" />
</span>
<span class="text-xs leading-6 text-ink/75">{{ t.title }}</span>
</li>
</ul>
</Reveal>
</div>
</div>
</div>
</section>
<!-- ---- description + technical specs ---- -->
<section class="relative border-t border-line py-20 md:py-28">
<div class="absolute inset-0 bg-grid-fine opacity-30" />
<div class="relative mx-auto grid max-w-[1480px] gap-14 px-5 md:px-8 lg:grid-cols-12">
<div class="lg:col-span-7">
<SectionHead
tag="توضیحات"
:title="paragraphs.length ? 'درباره این محصول' : `درباره ${product.category.name}`"
/>
<Reveal :delay="0.2">
<div class="mt-8 space-y-5 leading-9 text-ink/75">
<p v-for="(p, i) in paragraphs" :key="i" class="whitespace-pre-line">{{ p }}</p>
<p v-if="!paragraphs.length">{{ product.category.fullDesc }}</p>
</div>
</Reveal>
<div v-if="product.features.length" class="mt-14">
<Reveal>
<h3 class="flex items-center gap-3 text-xl font-black">
<span class="font-mono text-sm text-accent">//</span>
ویژگی‌های کلیدی
</h3>
</Reveal>
<ul class="mt-6 grid gap-3 sm:grid-cols-2">
<Reveal v-for="(f, i) in product.features" :key="f" :delay="i * 0.04">
<li class="flex h-full items-start gap-3 rounded-xl border border-line bg-panel/60 p-4">
<span class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent text-bg">
<Check class="size-3.5" :stroke-width="3" />
</span>
<span class="text-sm leading-7 text-ink/85">{{ f }}</span>
</li>
</Reveal>
</ul>
</div>
<div v-if="product.applications.length" class="mt-14">
<Reveal>
<h3 class="flex items-center gap-3 text-xl font-black">
<span class="font-mono text-sm text-accent">//</span>
کاربردها و محیط‌های مناسب
</h3>
</Reveal>
<Reveal :delay="0.1">
<div class="mt-6 flex flex-wrap gap-2.5">
<span
v-for="a in product.applications"
:key="a"
class="rounded-full border border-line bg-panel/60 px-4 py-2 text-sm text-ink/80"
>
{{ a }}
</span>
</div>
</Reveal>
</div>
</div>
<aside class="lg:col-span-5">
<div class="space-y-6 lg:sticky lg:top-28">
<Reveal :delay="0.1">
<div class="overflow-hidden rounded-2xl border border-line bg-panel/80">
<div class="flex items-center justify-between border-b border-line px-6 py-4">
<span class="font-mono text-xs tracking-[0.25em] text-accent">// مشخصات فنی</span>
<span class="font-mono text-[10px] tracking-[0.25em] text-muted/60">SPECIFICATIONS</span>
</div>
<dl>
<div
v-for="row in specRows"
:key="row.name"
class="grid grid-cols-5 gap-4 border-b border-line/70 px-6 py-3.5 last:border-b-0"
>
<dt class="col-span-2 text-sm text-muted">{{ row.name }}</dt>
<dd
class="col-span-3 text-sm font-medium text-ink/90"
:class="row.ltr && 'font-mono tracking-wider'"
:dir="row.ltr ? 'ltr' : undefined"
:style="row.ltr ? 'text-align: right' : undefined"
>
{{ row.value }}
</dd>
</div>
</dl>
</div>
</Reveal>
<Reveal :delay="0.18">
<div class="flex items-start gap-4 rounded-xl border border-hazard/30 bg-hazard/5 p-5">
<Headset class="size-6 shrink-0 text-hazard" />
<div>
<div class="font-bold text-hazard">مشاوره رایگان انتخاب تجهیزات</div>
<p class="mt-1.5 text-sm leading-7 text-muted">
برای انتخاب مدل و سایز مناسب محیط کار، خرید سازمانی یا دریافت نمونه، با کارشناسان ما تماس بگیرید.
</p>
<div class="mt-4 flex flex-wrap gap-3 text-sm">
<NuxtLink to="/#contact" class="font-bold text-accent hover:underline" @click="requestQuote">
ثبت درخواست
</NuxtLink>
<span class="text-line">|</span>
<a :href="`tel:${settings.phone}`" class="font-mono tracking-wider text-ink/80 hover:text-accent" dir="ltr">
{{ settings.phoneDisplay }}
</a>
</div>
</div>
</div>
</Reveal>
</div>
</aside>
</div>
</section>
<!-- ---- related products ---- -->
<section v-if="product.related.length" class="border-t border-line py-20 md:py-28">
<div class="mx-auto max-w-[1480px] px-5 md:px-8">
<div class="flex flex-wrap items-end justify-between gap-6">
<SectionHead tag="محصولات مرتبط" :title="`محصولات دیگر\n${product.category.name}`" />
<Reveal :delay="0.2">
<NuxtLink
:to="categoryPath"
class="group inline-flex items-center gap-2 rounded-full border border-line px-6 py-3 text-sm text-muted transition-colors hover:border-accent hover:text-accent"
>
مشاهده همه
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-1" />
</NuxtLink>
</Reveal>
</div>
<div class="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
<Reveal v-for="(r, i) in product.related" :key="r.code" :delay="i * 0.06">
<NuxtLink
:to="`/products/${r.slug}`"
class="group flex h-full flex-col overflow-hidden rounded-xl border border-line bg-panel/80 transition-all duration-500 hover:border-accent/40 hover:shadow-[0_8px_40px_rgba(255,90,0,0.08)]"
>
<div
class="relative aspect-[4/3] overflow-hidden"
:class="r.image && 'bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.14),transparent_70%)]'"
>
<img
:src="r.image ?? product.category.coverImage ?? undefined"
:alt="r.name"
loading="lazy"
decoding="async"
class="size-full transition-transform duration-700 group-hover:scale-105"
:class="r.image ? 'object-contain p-7' : 'object-cover'"
/>
<span class="absolute start-3 top-3 rounded-full bg-bg/60 px-2.5 py-0.5 font-mono text-[10px] tracking-widest text-ink/70 backdrop-blur">
{{ r.code }}
</span>
</div>
<div class="flex flex-1 flex-col p-5">
<h3 class="font-black transition-colors group-hover:text-accent">{{ r.name }}</h3>
<p class="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted">{{ r.nameEn }}</p>
<span class="mt-auto inline-flex items-center gap-2 pt-4 text-xs font-bold text-accent">
مشاهده محصول
<ArrowLeft class="size-3.5 transition-transform group-hover:-translate-x-1" />
</span>
</div>
</NuxtLink>
</Reveal>
</div>
</div>
</section>
<!-- ---- bottom CTA ---- -->
<section class="border-t border-line py-12 md:py-16">
<div class="mx-auto flex max-w-[1480px] flex-wrap items-center justify-between gap-6 px-5 md:px-8">
<NuxtLink
:to="categoryPath"
class="group inline-flex items-center gap-2 text-muted transition-colors hover:text-accent"
>
<ArrowRight class="size-4 transition-transform group-hover:translate-x-1" />
بازگشت به {{ product.category.name }}
</NuxtLink>
<NuxtLink
to="/#contact"
class="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-bold text-bg transition-all hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
@click="requestQuote"
>
<Phone class="size-4" />
درخواست قیمت این محصول
</NuxtLink>
</div>
</section>
<!-- ---- lightbox ---- -->
<!-- client-only: Nuxt cannot server-render a Teleport to <body>, which breaks hydration -->
<ClientOnly>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300"
enter-from-class="opacity-0"
leave-active-class="transition duration-200"
leave-to-class="opacity-0"
>
<div
v-if="lightbox && current"
class="fixed inset-0 z-[120] flex flex-col bg-bg/95 backdrop-blur-xl"
role="dialog"
aria-modal="true"
:aria-label="product.name"
@click.self="lightbox = false"
>
<div class="flex items-center justify-between px-5 py-4 md:px-8">
<span class="font-mono text-xs text-muted">
{{ faNum(active + 1) }} / {{ faNum(images.length) }} — {{ product.name }}
</span>
<button
type="button"
aria-label="بستن"
class="flex size-11 items-center justify-center rounded-lg border border-line transition-colors hover:border-accent hover:text-accent"
@click="lightbox = false"
>
<X class="size-5" />
</button>
</div>
<div class="relative flex min-h-0 flex-1 items-center justify-center px-5 pb-8" @click.self="lightbox = false">
<img :src="current.url" :alt="current.alt" class="max-h-full max-w-full object-contain" />
<template v-if="images.length > 1">
<button
type="button"
aria-label="تصویر قبلی"
class="absolute start-5 top-1/2 flex size-12 -translate-y-1/2 items-center justify-center rounded-full border border-line bg-bg/70 transition-colors hover:border-accent hover:text-accent"
@click="go(-1)"
>
<ChevronRight class="size-5" />
</button>
<button
type="button"
aria-label="تصویر بعدی"
class="absolute end-5 top-1/2 flex size-12 -translate-y-1/2 items-center justify-center rounded-full border border-line bg-bg/70 transition-colors hover:border-accent hover:text-accent"
@click="go(1)"
>
<ChevronLeft class="size-5" />
</button>
</template>
</div>
</div>
</Transition>
</Teleport>
</ClientOnly>
</main>
</template>
+5
View File
@@ -32,6 +32,11 @@ export const useContactStore = defineStore("contact", {
this.status = "error";
}
},
/** Pre-fills the message, e.g. from a product page's "request a quote" button */
prefill(message: string) {
if (this.status === "success") this.reset();
this.form.message = message;
},
reset() {
this.form = emptyForm();
this.status = "idle";
+9 -5
View File
@@ -53,11 +53,15 @@ export default defineNuxtConfig({
},
},
routeRules: {
"/": { swr: PAGE_CACHE_SECONDS },
"/categories/**": { swr: PAGE_CACHE_SECONDS },
"/sitemap.xml": { swr: 3600 },
"/manifest.webmanifest": { swr: 3600 },
// production only: in dev a cached page would serve stale HTML after code edits (hydration errors)
$production: {
routeRules: {
"/": { swr: PAGE_CACHE_SECONDS },
"/categories/**": { swr: PAGE_CACHE_SECONDS },
"/products/**": { swr: PAGE_CACHE_SECONDS },
"/sitemap.xml": { swr: 3600 },
"/manifest.webmanifest": { swr: 3600 },
},
},
nitro: {
+9 -2
View File
@@ -1,13 +1,20 @@
import type { PublicProductRef } from "@imen/shared";
interface SitemapEntry {
loc: string;
priority: string;
lastmod?: string;
}
export default defineEventHandler(async (event) => {
const categories = await fetchCategories();
const [categories, products] = await Promise.all([
fetchCategories(),
serverApi<PublicProductRef[]>("/public/products"),
]);
const pages: SitemapEntry[] = [
{ loc: "/", priority: "1" },
...categories.map((c) => ({ loc: `/categories/${c.slug}`, priority: "0.8" })),
...products.map((p) => ({ loc: `/products/${p.slug}`, priority: "0.6", lastmod: p.updatedAt })),
];
setHeader(event, "Content-Type", "application/xml; charset=utf-8");
@@ -15,7 +22,7 @@ export default defineEventHandler(async (event) => {
const urls = pages
.map(
(p) =>
`<url>\n<loc>${absUrl(p.loc)}</loc>\n<lastmod>${lastmod}</lastmod>\n<changefreq>weekly</changefreq>\n<priority>${p.priority}</priority>\n</url>`,
`<url>\n<loc>${absUrl(p.loc)}</loc>\n<lastmod>${p.lastmod ?? lastmod}</lastmod>\n<changefreq>weekly</changefreq>\n<priority>${p.priority}</priority>\n</url>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
+35 -1
View File
@@ -1,4 +1,4 @@
import type { PublicCategory, SiteSettings } from "@imen/shared";
import type { PublicCategory, PublicProductDetail, SiteSettings } from "@imen/shared";
import { siteConfig } from "../config/site";
type Json = Record<string, unknown>;
@@ -97,12 +97,46 @@ export const categoryPageSchema = (ctx: SeoContext, category: PublicCategory) =>
name: p.name,
description: p.desc,
sku: p.code,
url: abs(ctx, `/products/${p.slug}`),
...(p.image && { image: abs(ctx, p.image) }),
},
})),
},
});
export const productSchema = (ctx: SeoContext, p: PublicProductDetail) =>
clean({
"@context": "https://schema.org",
"@type": "Product",
"@id": `${ctx.siteUrl}/products/${p.slug}#product`,
name: p.name,
alternateName: p.nameEn,
sku: p.code,
mpn: p.code,
description: p.seoDescription || p.desc || p.details.slice(0, 300),
url: abs(ctx, `/products/${p.slug}`),
image: p.images.map((i) => abs(ctx, i.url)),
category: p.category.name,
...(p.brand && { brand: { "@type": "Brand", name: p.brand } }),
countryOfOrigin: p.origin,
additionalProperty: [
...p.attributes.map((a) => ({ "@type": "PropertyValue", name: a.name, value: a.value })),
...p.standards.map((s) => ({ "@type": "PropertyValue", name: "استاندارد", value: s })),
],
seller: { "@id": `${ctx.siteUrl}/#organization` },
});
export const breadcrumbSchema = (ctx: SeoContext, items: { name: string; path: string }[]) => ({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((it, i) => ({
"@type": "ListItem",
position: i + 1,
name: it.name,
item: abs(ctx, it.path),
})),
});
/** Serialize JSON-LD safely for an inline <script> (escape "<"). */
export const toJsonLd = (data: Json) =>
JSON.stringify(data).replace(/</g, "\\u003c");
+1 -1
View File
@@ -77,7 +77,7 @@ services:
MEDIA_PUBLIC_URL: /media
WEB_REVALIDATE_URL: http://web:3000/_internal/revalidate
REVALIDATE_SECRET: ${REVALIDATE_SECRET}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PHONE: ${ADMIN_PHONE}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_NAME: ${ADMIN_NAME:-مدیر سایت}
+28 -3
View File
@@ -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;
+53
View File
@@ -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;