feat: complete UI/UX improvements, vuesax icons, media server, and section management

This commit is contained in:
s.esmaeili
2026-10-23 21:06:15 +03:30
parent 49f30a96b1
commit fa61a76444
64 changed files with 4527 additions and 819 deletions
+6
View File
@@ -27,3 +27,9 @@ pnpm-debug.log*
# database dumps from the backup service
backups/
# local s3 mock storage
.s3data/
**/.s3data/
*.jpg
icons (1).json
+7
View File
@@ -75,3 +75,10 @@ export interface SectionResponse<K extends SectionKey = SectionKey> {
data: SiteSections[K];
updatedAt: string;
}
export interface AdminSectionSummary {
key: SectionKey;
updatedAt: string | null;
enabled: boolean;
}
+27
View File
@@ -46,4 +46,31 @@ defineProps<{ lockBody?: boolean }>();
flex-direction: column;
overflow: hidden;
}
@media (max-width: 1023px) {
.ap {
height: auto;
min-height: 100%;
overflow: visible;
}
.ap__head {
position: sticky;
top: 52px;
z-index: 20;
padding: 10px 12px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03);
}
.ap__body {
padding: 12px;
overflow: visible;
}
.ap__body--locked {
overflow: visible;
height: auto;
}
}
</style>
+9 -11
View File
@@ -1,20 +1,18 @@
<script setup lang="ts">
import { ICON_NAMES, type IconName } from "@imen/shared";
import { computed } from "vue";
import { ICONS } from "./icons";
import { RECOMMENDED_ICONS } from "@imen/shared";
import VuesaxIcon from "./VuesaxIcon.vue";
/** Picks one of the Lucide icons the website knows how to render */
const value = defineModel<string>({ required: true });
defineProps<{ allowClear?: boolean }>();
const options = ICON_NAMES.map((name) => ({ value: name, label: name }));
const current = computed(() => ICONS[value.value as IconName]);
const options = RECOMMENDED_ICONS.map((name) => ({ value: name, label: name }));
</script>
<template>
<div class="flex items-center gap-2">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-gray-200 text-[#ff5a00]">
<component :is="current" v-if="current" class="size-4" />
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-[#ff5a00]">
<VuesaxIcon v-if="value" :name="value" :size="20" />
<span v-else class="text-xs text-gray-400">---</span>
</span>
<a-select
v-model:value="value"
@@ -25,9 +23,9 @@ const current = computed(() => ICONS[value.value as IconName]);
placeholder="انتخاب آیکون"
>
<template #option="{ value: name }">
<span class="flex items-center gap-2" dir="ltr">
<component :is="ICONS[name as IconName]" class="size-4" />
{{ name }}
<span class="flex items-center gap-2.5 py-0.5" dir="ltr">
<VuesaxIcon :name="name" :size="18" class="text-orange-600" />
<span class="text-xs font-mono">{{ name }}</span>
</span>
</template>
</a-select>
+31 -8
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import VuesaxIcon from "@/components/VuesaxIcon.vue";
defineProps<{ title: string; subtitle?: string; back?: string }>();
</script>
<template>
<div class="page-header">
<div class="page-header__main">
<RouterLink v-if="back" :to="back" class="page-header__back" title="بازگشت">→</RouterLink>
<RouterLink v-if="back" :to="back" class="page-header__back" title="بازگشت">
<VuesaxIcon name="arrow-right" :size="18" />
</RouterLink>
<div class="min-w-0">
<h1 class="page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
@@ -19,42 +23,61 @@ defineProps<{ title: string; subtitle?: string; back?: string }>();
<style scoped>
.page-header {
flex: 0 0 auto !important;
flex-shrink: 0 !important;
flex-grow: 0 !important;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 10px;
}
.page-header__main {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
min-width: 0;
}
.page-header__back {
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: #f1f5f9;
color: #475569;
text-decoration: none;
transition: color 0.15s ease;
transition: all 0.15s ease;
flex-shrink: 0;
}
.page-header__back:hover {
color: #334155;
background: #e2e8f0;
color: #0f172a;
}
.page-header__title {
margin: 0;
font-size: 18px;
font-size: 16px;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
}
@media (min-width: 640px) {
.page-header__title {
font-size: 18px;
}
}
.page-header__subtitle {
margin: 2px 0 0;
font-size: 13px;
font-size: 12px;
color: #94a3b8;
line-height: 1.4;
}
.page-header__actions {
+7 -6
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from "vue";
import { resolveVuesaxIcon } from "@imen/shared";
import icons from "@/icons/vuesax-linear.json";
const props = withDefaults(
defineProps<{
/** Short name (`add`) or full key (`vuesax-linear:add`) */
/** Short name (`add`), alias (`ShieldCheck`, `Phone`), or full key (`vuesax-linear:add`) */
name: string;
size?: number | string;
}>(),
@@ -14,10 +15,9 @@ const props = withDefaults(
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];
const resolved = resolveVuesaxIcon(props.name || "");
const key = resolved.includes(":") ? resolved : `vuesax-linear:${resolved}`;
const raw = pack[key] || pack[`vuesax-linear:${resolved.toLowerCase()}`] || pack["vuesax-linear:shield-tick"];
if (!raw) return "";
return raw
.replace(/\swidth="24"/, "")
@@ -33,7 +33,7 @@ const box = computed(() =>
<template>
<span
class="vx-icon"
class="anticon vx-icon"
:style="{ width: box, height: box }"
:aria-hidden="true"
v-html="svgHtml"
@@ -48,6 +48,7 @@ const box = computed(() =>
justify-content: center;
line-height: 0;
color: inherit;
vertical-align: -0.15em;
}
.vx-icon :deep(svg) {
+2 -104
View File
@@ -1,104 +1,2 @@
import type { IconName } from "@imen/shared";
import {
Award,
BadgeCheck,
Building2,
ClipboardCheck,
Clock,
Construction,
Ear,
Factory,
FileCheck,
FileSearch,
Flame,
Footprints,
Fuel,
Gauge,
Glasses,
Globe,
GraduationCap,
Hand,
Handshake,
HardHat,
Headset,
HeartPulse,
Leaf,
Lock,
Mail,
MapPin,
Medal,
Microscope,
Package,
PackageCheck,
Phone,
Pickaxe,
ShieldAlert,
ShieldCheck,
ShieldPlus,
Shirt,
Siren,
Star,
Target,
ThumbsUp,
TrafficCone,
TriangleAlert,
Truck,
Users,
Warehouse,
Wind,
Wrench,
Zap,
} from "lucide-vue-next";
import type { Component } from "vue";
/** Same icon set as the website's DynamicIcon (ICON_NAMES in @imen/shared) */
export const ICONS: Record<IconName, Component> = {
ShieldCheck,
ShieldPlus,
ShieldAlert,
BadgeCheck,
Award,
Medal,
PackageCheck,
Package,
Truck,
Warehouse,
Globe,
Users,
Handshake,
Headset,
ClipboardCheck,
FileCheck,
FileSearch,
GraduationCap,
Gauge,
Microscope,
Wrench,
HardHat,
Shirt,
Footprints,
Hand,
Glasses,
Ear,
Wind,
TriangleAlert,
Siren,
TrafficCone,
Construction,
Factory,
Building2,
Fuel,
Pickaxe,
Flame,
Zap,
Leaf,
HeartPulse,
Clock,
Phone,
Mail,
MapPin,
Star,
ThumbsUp,
Target,
Lock,
};
export { ICON_ALIASES, ICON_NAMES, RECOMMENDED_ICONS, resolveVuesaxIcon } from "@imen/shared";
export type { IconName } from "@imen/shared";
+509 -68
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ROLE_LABELS } from "@imen/shared";
import { computed, h, ref, watch } from "vue";
import { computed, h, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useAuthStore } from "@/stores/auth";
import { SITE_URL } from "@/utils/format";
@@ -10,13 +11,28 @@ const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const drawer = ref(false);
const unreadCount = ref(0);
async function fetchUnread() {
try {
const res = await api<{ total: number }>("/admin/messages", {
query: { status: "NEW", pageSize: 1 },
});
unreadCount.value = res.total;
} catch {
// ignore
}
}
onMounted(fetchUnread);
watch(() => route.fullPath, fetchUnread);
/** 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 })]);
h("span", { class: "admin-menu__icon" }, [h(VuesaxIcon, { name, size: 20 })]);
const items = computed(() => [
{ key: "dashboard", icon: menuIcon("element-3"), label: "داشبورد" },
{ key: "dashboard", icon: menuIcon("chart-square"), label: "داشبورد" },
{ key: "products", icon: menuIcon("bag-2"), label: "محصولات" },
{ key: "categories", icon: menuIcon("category"), label: "دسته‌بندی‌ها" },
{ key: "sections", icon: menuIcon("row-vertical"), label: "بخش‌های سایت" },
@@ -58,16 +74,35 @@ const selected = computed(() => {
const pageTitle = computed(() => PAGE_TITLES[String(route.name ?? "")] ?? "پنل مدیریت");
const userInitial = computed(() => (auth.user?.name?.trim().charAt(0) || "ک").slice(0, 1));
const isChildRoute = computed(() => {
const name = String(route.name ?? "");
return ["product-new", "product-edit", "category-new", "category-edit", "section-edit"].includes(name);
});
const parentBack = computed(() => {
const name = String(route.name ?? "");
if (name.startsWith("product")) return { name: "products", label: "محصولات" };
if (name.startsWith("category")) return { name: "categories", label: "دسته‌بندی‌ها" };
if (name.startsWith("section")) return { name: "sections", label: "بخش‌های سایت" };
return { name: "dashboard", label: "داشبورد" };
});
const go = ({ key }: { key: string | number }) => {
drawer.value = false;
void router.push({ name: String(key) });
};
const navigateTo = (name: string) => {
drawer.value = false;
void router.push({ name });
};
watch(() => route.fullPath, () => (drawer.value = false));
</script>
<template>
<a-layout class="admin-shell">
<!-- Desktop Sider (lg and up) -->
<a-layout-sider
:width="248"
breakpoint="lg"
@@ -97,42 +132,250 @@ watch(() => route.fullPath, () => (drawer.value = false));
</div>
</a-layout-sider>
<!-- Mobile App Hub Drawer (Rich App Grid) -->
<a-drawer
v-model:open="drawer"
placement="right"
:width="280"
:width="320"
:closable="false"
class="admin-drawer"
:body-style="{ padding: 0, background: '#ffffff' }"
class="mobile-hub-drawer"
:body-style="{ padding: 0, background: '#f8fafc' }"
>
<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 class="flex flex-col h-full">
<!-- Hub User Profile Header -->
<div class="p-4 bg-white border-b border-slate-200/80">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
<span class="size-8 rounded-lg bg-orange-50 border border-orange-100 flex items-center justify-center">
<img src="/logo.png" alt="" class="size-5 object-contain" />
</span>
<div>
<div class="text-sm font-bold text-slate-800">ایمن پیشگامان</div>
<div class="text-[10px] text-slate-400">اپلیکیشن مدیریت</div>
</div>
</div>
<button
type="button"
class="size-8 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition"
@click="drawer = false"
>
<VuesaxIcon name="close-circle" :size="20" />
</button>
</div>
<div
class="flex items-center gap-3 p-2.5 rounded-xl bg-slate-50 border border-slate-100 cursor-pointer active:scale-[0.99] transition"
@click="navigateTo('profile')"
>
<span class="size-10 rounded-full bg-linear-to-br from-[#ff5a00] to-[#ff8a3d] text-white flex items-center justify-center font-bold text-sm shadow-xs">
{{ userInitial }}
</span>
<div class="min-w-0 flex-1">
<div class="text-xs font-bold text-slate-800 truncate">{{ auth.user?.name }}</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[10px] text-orange-600 font-medium">
{{ auth.user && ROLE_LABELS[auth.user.role] }}
</span>
<span class="text-[10px] text-slate-400 font-mono" dir="ltr">{{ auth.user?.phone }}</span>
</div>
</div>
<VuesaxIcon name="arrow-left" :size="14" class="text-slate-400" />
</div>
</div>
<!-- Hub Apps Grid Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div>
<div class="text-[11px] font-bold text-slate-400 mb-2.5 px-1">بخش‌ها و کاتالوگ</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-orange-200 active:bg-orange-50/40 transition"
:class="{ '!border-orange-500 !bg-orange-50/30 font-bold text-orange-600': route.name === 'dashboard' }"
@click="navigateTo('dashboard')"
>
<div class="size-9 rounded-lg bg-orange-50 text-orange-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="chart-square" :size="18" />
</div>
<span class="text-xs text-slate-700">داشبورد</span>
<span class="text-[9px] text-slate-400 mt-0.5">نمای کلی آمار</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-orange-200 active:bg-orange-50/40 transition"
:class="{ '!border-orange-500 !bg-orange-50/30 font-bold text-orange-600': selected.includes('products') }"
@click="navigateTo('products')"
>
<div class="size-9 rounded-lg bg-orange-50 text-orange-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="bag-2" :size="18" />
</div>
<span class="text-xs text-slate-700">محصولات</span>
<span class="text-[9px] text-slate-400 mt-0.5">کاتالوگ و موجودی</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-blue-200 active:bg-blue-50/40 transition"
:class="{ '!border-blue-500 !bg-blue-50/30 font-bold text-blue-600': selected.includes('categories') }"
@click="navigateTo('categories')"
>
<div class="size-9 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="category" :size="18" />
</div>
<span class="text-xs text-slate-700">دسته‌بندی‌ها</span>
<span class="text-[9px] text-slate-400 mt-0.5">گروه‌های کالا</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-amber-200 active:bg-amber-50/40 transition relative"
:class="{ '!border-amber-500 !bg-amber-50/30 font-bold text-amber-600': route.name === 'messages' }"
@click="navigateTo('messages')"
>
<span v-if="unreadCount > 0" class="absolute top-2 end-2 size-2.5 rounded-full bg-orange-600 ring-2 ring-white animate-pulse" />
<div class="size-9 rounded-lg bg-amber-50 text-amber-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="sms" :size="18" />
</div>
<span class="text-xs text-slate-700">پیام‌های تماس</span>
<span class="text-[9px] text-slate-400 mt-0.5">درخواست‌های مشاوره</span>
</button>
</div>
</div>
<div>
<div class="text-[11px] font-bold text-slate-400 mb-2.5 px-1">محتوا و وب‌سایت</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-purple-200 active:bg-purple-50/40 transition"
:class="{ '!border-purple-500 !bg-purple-50/30 font-bold text-purple-600': route.name === 'media' }"
@click="navigateTo('media')"
>
<div class="size-9 rounded-lg bg-purple-50 text-purple-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="gallery" :size="18" />
</div>
<span class="text-xs text-slate-700">کتابخانه رسانه</span>
<span class="text-[9px] text-slate-400 mt-0.5">تصاویر WebP</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-emerald-200 active:bg-emerald-50/40 transition"
:class="{ '!border-emerald-500 !bg-emerald-50/30 font-bold text-emerald-600': selected.includes('sections') }"
@click="navigateTo('sections')"
>
<div class="size-9 rounded-lg bg-emerald-50 text-emerald-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="row-vertical" :size="18" />
</div>
<span class="text-xs text-slate-700">بخش‌های سایت</span>
<span class="text-[9px] text-slate-400 mt-0.5">تنظیمات صفحه اصلی</span>
</button>
</div>
</div>
<div v-if="auth.isAdmin">
<div class="text-[11px] font-bold text-slate-400 mb-2.5 px-1">مدیریت سیستم</div>
<div class="grid grid-cols-1 gap-2">
<button
type="button"
class="flex items-center gap-3 p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-slate-300 active:bg-slate-50 transition text-right"
:class="{ '!border-orange-500 font-bold': route.name === 'users' }"
@click="navigateTo('users')"
>
<div class="size-9 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center shrink-0">
<VuesaxIcon name="profile-2user" :size="18" />
</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-bold text-slate-800">مدیریت کاربران پنل</div>
<div class="text-[10px] text-slate-400">حساب‌ها و سطح دسترسی‌ها</div>
</div>
<VuesaxIcon name="arrow-left" :size="14" class="text-slate-400" />
</button>
</div>
</div>
</div>
<!-- Hub Footer System Actions -->
<div class="p-3 bg-white border-t border-slate-200/80 space-y-2">
<a
:href="SITE_URL"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-2.5 rounded-lg text-slate-600 hover:bg-slate-50 text-xs font-semibold transition"
>
<span class="flex items-center gap-2">
<VuesaxIcon name="export" :size="16" class="text-slate-400" />
مشاهده وب‌سایت اصلی
</span>
<VuesaxIcon name="arrow-left" :size="12" class="text-slate-300" />
</a>
<button
type="button"
class="w-full flex items-center justify-center gap-2 p-2.5 rounded-lg text-rose-600 hover:bg-rose-50 text-xs font-semibold transition"
@click="auth.logout()"
>
<VuesaxIcon name="logout" :size="16" />
خروج از حساب کاربری
</button>
</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="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>
<!-- Dedicated Mobile App Bar (Mobile Only) -->
<header class="mobile-app-bar lg:!hidden">
<div v-if="isChildRoute" class="flex items-center justify-between w-full">
<button
type="button"
class="mobile-app-bar__back-btn"
@click="router.push(parentBack)"
>
<VuesaxIcon name="arrow-right" :size="20" />
<span class="text-xs font-bold text-slate-700">بازگشت به {{ parentBack.label }}</span>
</button>
<div class="text-xs font-bold text-slate-800 truncate max-w-[140px]">
{{ pageTitle }}
</div>
</div>
<div v-else class="flex items-center justify-between w-full">
<div class="flex items-center gap-2.5">
<span class="size-8 rounded-lg bg-orange-50 border border-orange-100 flex items-center justify-center">
<img src="/logo.png" alt="" class="size-5 object-contain" />
</span>
<div class="leading-none">
<div class="text-xs font-extrabold text-slate-800">ایمن پیشگامان</div>
<div class="text-[10px] font-medium text-orange-600 mt-0.5">{{ pageTitle }}</div>
</div>
</div>
<div class="flex items-center gap-1.5">
<a
:href="SITE_URL"
target="_blank"
rel="noopener"
class="size-8 rounded-lg flex items-center justify-center text-slate-500 hover:text-orange-600 hover:bg-orange-50 transition"
title="مشاهده سایت"
>
<VuesaxIcon name="export" :size="18" />
</a>
<button
type="button"
class="size-8 rounded-lg bg-linear-to-br from-[#ff5a00] to-[#ff8a3d] text-white flex items-center justify-center font-bold text-xs shadow-xs"
@click="router.push({ name: 'profile' })"
>
{{ userInitial }}
</button>
</div>
</div>
</header>
<!-- Desktop Header (Desktop Only) -->
<a-layout-header class="admin-header !hidden lg:!flex">
<div class="admin-header__start">
<div class="admin-header__crumb">
<span class="admin-header__crumb-kicker">پنل مدیریت</span>
<span class="admin-header__crumb-sep" aria-hidden="true">/</span>
@@ -179,13 +422,69 @@ watch(() => route.fullPath, () => (drawer.value = false));
</div>
</a-layout-header>
<a-layout-content class="admin-content">
<!-- Content Viewport -->
<a-layout-content class="admin-content" :class="{ 'admin-content--has-bottom-nav': !isChildRoute }">
<div class="admin-page">
<RouterView v-slot="{ Component, route: r }">
<component :is="Component" :key="r.path" />
</RouterView>
</div>
</a-layout-content>
<!-- Purpose-Built Mobile Bottom Navigation Bar -->
<nav v-if="!isChildRoute" class="mobile-nav-bar lg:!hidden">
<button
type="button"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': route.name === 'dashboard' }"
@click="navigateTo('dashboard')"
>
<div class="mobile-nav-item__icon">
<VuesaxIcon name="chart-square" :size="20" />
</div>
<span class="mobile-nav-item__label">داشبورد</span>
</button>
<button
type="button"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': selected.includes('products') }"
@click="navigateTo('products')"
>
<div class="mobile-nav-item__icon">
<VuesaxIcon name="bag-2" :size="20" />
</div>
<span class="mobile-nav-item__label">محصولات</span>
</button>
<button
type="button"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': route.name === 'messages' }"
@click="navigateTo('messages')"
>
<div class="mobile-nav-item__icon relative">
<VuesaxIcon name="sms" :size="20" />
<span
v-if="unreadCount > 0"
class="absolute -top-1 -end-1 size-2 rounded-full bg-orange-600 ring-2 ring-white"
/>
</div>
<span class="mobile-nav-item__label">پیام‌ها</span>
</button>
<button
type="button"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': drawer || ['categories', 'sections', 'media', 'users', 'profile'].includes(String(route.name ?? '')) }"
@click="drawer = true"
>
<div class="mobile-nav-item__icon">
<VuesaxIcon name="category" :size="20" />
</div>
<span class="mobile-nav-item__label">ابزارها</span>
</button>
</nav>
</a-layout>
</a-layout>
</template>
@@ -247,14 +546,22 @@ watch(() => route.fullPath, () => (drawer.value = false));
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.admin-page > :deep(*) {
.admin-page > :deep(.page-header) {
flex: 0 0 auto !important;
overflow: visible !important;
padding: 16px 20px 14px !important;
border-bottom: 1px solid #eef1f6;
background: #ffffff;
}
.admin-page > :deep(*:not(.page-header)) {
flex: 1;
min-width: 0;
min-height: 0;
}
/* Pages that don't use AdminPage still scroll inside the panel */
.admin-page > :deep(*:not(.ap)) {
.admin-page > :deep(*:not(.ap):not(.page-header)) {
overflow: auto;
padding: 16px;
box-sizing: border-box;
@@ -276,8 +583,9 @@ watch(() => route.fullPath, () => (drawer.value = false));
.admin-header__menu-btn {
border-radius: 10px !important;
border-color: #e2e8f0 !important;
border: none !important;
color: #475569 !important;
background: transparent !important;
}
.admin-header__crumb {
@@ -325,26 +633,21 @@ watch(() => route.fullPath, () => (drawer.value = false));
align-items: center;
gap: 8px;
height: 36px;
padding-inline: 14px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #ffffff;
padding-inline: 8px;
border: none !important;
background: transparent !important;
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;
transition: color 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);
color: #ff5a00;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.admin-header__divider {
@@ -362,7 +665,7 @@ watch(() => route.fullPath, () => (drawer.value = false));
padding-block: 4px;
padding-inline: 4px 10px;
border: 1px solid #e8ecf2;
border-radius: 999px;
border-radius: var(--ant-border-radius, 8px);
background: #f8fafc;
color: inherit;
cursor: pointer;
@@ -385,7 +688,7 @@ watch(() => route.fullPath, () => (drawer.value = false));
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 999px;
border-radius: calc(var(--ant-border-radius, 8px) - 2px);
background: linear-gradient(135deg, #ff5a00, #ff8a3d);
color: #ffffff;
font-size: 14px;
@@ -498,14 +801,14 @@ watch(() => route.fullPath, () => (drawer.value = false));
.admin-menu :deep(.ant-menu-item) {
display: flex;
align-items: center;
height: 44px !important;
height: 48px !important;
margin: 0 0 6px !important;
padding-inline: 12px !important;
padding-inline: 10px 14px !important;
border: 1px solid transparent !important;
border-radius: 12px !important;
color: #475569 !important;
font-weight: 500;
line-height: 44px !important;
line-height: 48px !important;
transition:
background 0.2s ease,
color 0.2s ease,
@@ -516,11 +819,13 @@ watch(() => route.fullPath, () => (drawer.value = false));
.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;
align-items: center !important;
justify-content: center !important;
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
min-height: 36px !important;
margin-inline-end: 12px !important;
font-size: 0 !important;
background: transparent !important;
box-shadow: none !important;
@@ -528,12 +833,14 @@ watch(() => route.fullPath, () => (drawer.value = false));
.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;
align-items: center !important;
justify-content: center !important;
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
min-height: 36px !important;
flex-shrink: 0 !important;
border-radius: 10px !important;
background: #f1f5f9;
color: #64748b;
transition:
@@ -543,11 +850,22 @@ watch(() => route.fullPath, () => (drawer.value = false));
}
.admin-menu :deep(.admin-menu__icon .vx-icon) {
width: 16px !important;
height: 16px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 20px !important;
min-height: 20px !important;
background: transparent !important;
box-shadow: none !important;
color: inherit;
color: inherit !important;
}
.admin-menu :deep(.admin-menu__icon svg) {
width: 20px !important;
height: 20px !important;
display: block !important;
}
.admin-menu :deep(.ant-menu-item:hover) {
@@ -556,9 +874,9 @@ watch(() => route.fullPath, () => (drawer.value = false));
border-color: #eef1f6 !important;
}
.admin-menu :deep(.ant-menu-item:hover .admin-menu__icon) {
background: #e8eef7;
color: #475569;
.admin-menu :deep(.ant-menu-item:hover:not(.ant-menu-item-selected) .admin-menu__icon) {
background: #e8eef7 !important;
color: #475569 !important;
}
.admin-menu :deep(.ant-menu-item-selected) {
@@ -572,10 +890,133 @@ watch(() => route.fullPath, () => (drawer.value = false));
.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);
box-shadow: 0 4px 12px rgba(255, 90, 0, 0.35) !important;
}
.admin-menu :deep(.ant-menu-item-selected::after) {
display: none !important;
}
/* -------------------------------------------------------------
* Mobile App Experience Styles (Viewport < 1024px)
* ------------------------------------------------------------- */
.mobile-app-bar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
height: 52px;
padding-inline: 14px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
border-bottom: 1px solid #eef1f6;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.02);
}
.mobile-app-bar__back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
margin-inline-start: -6px;
border: none;
background: transparent;
color: #334155;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.mobile-app-bar__back-btn:active {
background: #f1f5f9;
}
.mobile-nav-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-around;
height: 60px;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(16px);
border-top: 1px solid #e2e8f0;
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04);
}
.mobile-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding-block: 4px;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
transition: color 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.mobile-nav-item__icon {
display: flex;
align-items: center;
justify-content: center;
height: 24px;
transition: transform 0.15s ease;
}
.mobile-nav-item__label {
margin-top: 2px;
font-size: 10px;
font-weight: 600;
line-height: 1;
}
.mobile-nav-item--active {
color: #ff5a00;
}
.mobile-nav-item--active .mobile-nav-item__icon {
transform: translateY(-1px);
}
@media (max-width: 1023px) {
.admin-content {
padding: 0 !important;
background: #f8fafc !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
.admin-content--has-bottom-nav {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)) !important;
}
.admin-page {
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
min-height: 100%;
}
.admin-page > :deep(.page-header) {
padding: 12px 14px !important;
border-bottom: 1px solid #eef1f6 !important;
background: #ffffff !important;
}
.admin-page > :deep(*:not(.ap):not(.page-header)) {
padding: 12px !important;
}
}
</style>
+132 -1
View File
@@ -5,8 +5,10 @@ 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 { useAuthStore } from "@/stores/auth";
import { faNumber, SITE_URL } from "@/utils/format";
const auth = useAuthStore();
const rows = ref<AdminCategory[]>([]);
const loading = ref(false);
@@ -67,7 +69,7 @@ onMounted(load);
</RouterLink>
</PageHeader>
<a-card :body-style="{ padding: 0 }">
<a-card class="!hidden lg:!block" :body-style="{ padding: 0 }">
<a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 800 }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'cover'">
@@ -108,6 +110,7 @@ onMounted(load);
<a-button size="small" title="مشاهده در سایت"><template #icon><VuesaxIcon name="export" :size="16" /></template></a-button>
</a>
<a-popconfirm
v-if="auth.isAdmin"
title="این دسته حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@@ -122,4 +125,132 @@ onMounted(load);
</template>
</a-table>
</a-card>
<!-- Mobile Category Cards View (Mobile Only) -->
<div class="mobile-categories-view block lg:!hidden pb-24">
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="دسته‌بندی ثبت نشده است" class="py-12" />
<div v-else class="space-y-3">
<div
v-for="(record, index) in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3.5 shadow-2xs transition"
>
<!-- Top Row: Cover + Info -->
<div class="flex items-start gap-3">
<img
v-if="record.coverImage"
:src="record.coverImage.url"
alt=""
class="size-14 rounded-lg object-cover shrink-0 border border-slate-100 bg-slate-50"
/>
<div v-else class="size-14 rounded-lg bg-slate-100 shrink-0 flex items-center justify-center text-slate-300">
<VuesaxIcon name="category" :size="22" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<RouterLink
:to="{ name: 'category-edit', params: { id: record.id } }"
class="font-bold text-[14px] text-slate-800 hover:text-orange-600 line-clamp-1"
>
{{ record.name }}
</RouterLink>
<span class="text-[10px] font-mono text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded shrink-0" dir="ltr">
{{ record.code }}
</span>
</div>
<div class="flex items-center gap-2 mt-1.5 text-xs">
<RouterLink
:to="{ name: 'products', query: { categoryId: record.id } }"
class="inline-flex items-center gap-1 text-orange-600 font-bold bg-orange-50 px-2 py-0.5 rounded-full"
>
{{ faNumber(record.productCount) }} محصول
</RouterLink>
<a-tag :color="record.isPublished ? 'green' : 'default'" class="!m-0 !text-[10px]">
{{ record.isPublished ? "منتشرشده" : "مخفی" }}
</a-tag>
</div>
</div>
</div>
<!-- Bottom Row: Reorder Controls + Actions -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-3">
<!-- Touch Reorder Buttons -->
<div class="flex items-center gap-1">
<span class="text-[11px] text-slate-400 me-1">ترتیب:</span>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === 0"
@click="move(index, -1)"
>
<VuesaxIcon name="arrow-up" :size="16" />
</button>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === rows.length - 1"
@click="move(index, 1)"
>
<VuesaxIcon name="arrow-down" :size="16" />
</button>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-1.5">
<RouterLink :to="{ name: 'category-edit', params: { id: record.id } }">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="ویرایش"
>
<VuesaxIcon name="edit-2" :size="16" />
</button>
</RouterLink>
<a :href="`${SITE_URL}/categories/${record.slug}`" target="_blank" rel="noopener">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="مشاهده در سایت"
>
<VuesaxIcon name="export" :size="16" />
</button>
</a>
<a-popconfirm
v-if="auth.isAdmin"
title="این دسته حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as AdminCategory)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 disabled:opacity-30 disabled:pointer-events-none active:bg-rose-200 transition"
:disabled="record.productCount > 0"
:title="record.productCount > 0 ? 'ابتدا محصولات این دسته را حذف کنید' : 'حذف'"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</a-spin>
<!-- Mobile Floating Action Button (FAB) for New Category -->
<RouterLink
:to="{ name: 'category-new' }"
class="fixed start-4 bottom-[72px] z-30 flex items-center gap-2 px-4 py-2.5 rounded-full bg-linear-to-r from-[#ff5a00] to-[#ff7a29] text-white font-bold text-xs shadow-lg shadow-orange-500/30 active:scale-95 transition"
>
<VuesaxIcon name="add" :size="18" />
<span>دسته جدید</span>
</RouterLink>
</div>
</template>
+72 -7
View File
@@ -102,6 +102,8 @@ const groupTitle = (g: Group) => {
return count ? `${g.name} (${count} محصول)` : g.name;
};
const mobileTab = ref<"main" | "groups" | "media" | "seo">("main");
onMounted(load);
</script>
@@ -113,11 +115,50 @@ onMounted(load);
</a-button>
</PageHeader>
<!-- Mobile Segmented Workflow Tabs -->
<div class="lg:hidden mb-4 overflow-x-auto pb-1 scrollbar-none">
<div class="flex items-center gap-1.5 min-w-max bg-gray-100 p-1 rounded-xl">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'main' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'main'"
>
اطلاعات اصلی
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all flex items-center gap-1"
:class="mobileTab === 'groups' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'groups'"
>
<span>زیرگروه‌ها</span>
<span class="rounded-full bg-gray-200 px-1.5 py-0.2 text-[10px]">{{ model.groups.length }}</span>
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'media' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'media'"
>
کاور و آیکون
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'seo' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'seo'"
>
سئو و انتشار
</button>
</div>
</div>
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="save">
<a-form layout="vertical" class="pb-20 lg:pb-0" @submit.prevent="save">
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<a-card title="اطلاعات اصلی">
<a-card title="اطلاعات اصلی" :class="mobileTab !== 'main' && 'max-lg:hidden'">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام دسته" required>
<a-input v-model:value="model.name" />
@@ -144,7 +185,7 @@ onMounted(load);
</div>
</a-card>
<a-card title="زیرگروه‌ها" :body-style="{ background: '#fafafa' }">
<a-card title="زیرگروه‌ها" :body-style="{ background: '#fafafa' }" :class="mobileTab !== 'groups' && 'max-lg:hidden'">
<p class="mb-4 text-sm text-gray-500">
زیرگروه‌ها در صفحه دسته به‌صورت فیلتر نمایش داده می‌شوند. با حذف یک زیرگروه، محصولات آن بدون زیرگروه می‌مانند.
</p>
@@ -172,23 +213,23 @@ onMounted(load);
</div>
<div class="space-y-6">
<a-card title="انتشار">
<a-card title="انتشار" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "مخفی" }}</span>
</FormField>
</a-card>
<a-card title="تصویر کاور">
<a-card title="تصویر کاور" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="coverImageId">
<ImageField :url="cover?.url" clearable @pick="pickCover" @clear="cover = null; model.coverImageId = null" />
</FormField>
</a-card>
<a-card title="آیکون">
<a-card title="آیکون" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="icon">
<IconSelect v-model="model.icon" allow-clear />
</FormField>
</a-card>
<a-card title="سئو">
<a-card title="سئو" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام دسته">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField>
@@ -200,4 +241,28 @@ onMounted(load);
</div>
</a-form>
</a-spin>
<!-- Mobile Sticky Bottom Action Bar -->
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-2.5 backdrop-blur-md shadow-lg flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<a-switch v-model:checked="model.isPublished" size="small" />
<span class="text-xs font-medium text-gray-700">{{ model.isPublished ? "انتشار" : "پیش‌نویس" }}</span>
</div>
<div class="flex items-center gap-2">
<a
v-if="model.slug"
:href="`/categories/${model.slug}`"
target="_blank"
rel="noopener"
>
<a-button size="middle" class="!px-3" title="مشاهده در سایت">
<template #icon><VuesaxIcon name="eye" :size="16" /></template>
</a-button>
</a>
<a-button type="primary" size="middle" class="!font-bold !px-5" :loading="saving" @click="save">
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره تغییرات
</a-button>
</div>
</div>
</template>
+290 -36
View File
@@ -1,84 +1,338 @@
<script setup lang="ts">
import { type DashboardDto, MESSAGE_STATUS_LABELS } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { activityLabel, faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const data = ref<DashboardDto | null>(null);
const loading = ref(true);
const maxCategoryCount = computed(() => {
if (!data.value?.categoryStats?.length) return 1;
return Math.max(...data.value.categoryStats.map((c) => c.count), 1);
});
const maxTrendCount = computed(() => {
if (!data.value?.trend?.length) return 1;
return Math.max(...data.value.trend.map((t) => t.messages), 1);
});
const sectionPercent = computed(() => {
if (!data.value?.sectionsSummary?.total) return 100;
return Math.round((data.value.sectionsSummary.visible / data.value.sectionsSummary.total) * 100);
});
onMounted(async () => {
loading.value = true;
try {
data.value = await api<DashboardDto>("/admin/dashboard");
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
});
</script>
<template>
<PageHeader title="داشبورد" subtitle="خلاصه وضعیت محتوای سایت">
<RouterLink :to="{ name: 'product-new' }">
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
<PageHeader title="داشبورد مدیریت" subtitle="نمای کلی آمار، نمودارها، پیام‌ها و فعالیت‌های اخیر سامانه">
<div class="flex items-center gap-2">
<RouterLink :to="{ name: 'sections' }">
<a-button>
<template #icon><VuesaxIcon name="row-vertical" :size="16" /></template>
مدیریت بخش‌ها
</a-button>
</RouterLink>
<RouterLink :to="{ name: 'product-new' }">
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</div>
</PageHeader>
<a-spin :spinning="!data">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
<RouterLink :to="{ name: 'products' }">
<a-card hoverable><a-statistic title="محصولات" :value="faNumber(data?.products ?? 0)" /></a-card>
</RouterLink>
<RouterLink :to="{ name: 'products', query: { status: 'draft' } }">
<a-card hoverable><a-statistic title="پیش‌نویس" :value="faNumber(data?.draftProducts ?? 0)" /></a-card>
</RouterLink>
<RouterLink :to="{ name: 'categories' }">
<a-card hoverable><a-statistic title="دسته‌بندی‌ها" :value="faNumber(data?.categories ?? 0)" /></a-card>
</RouterLink>
<RouterLink :to="{ name: 'messages', query: { status: 'NEW' } }">
<a-card hoverable>
<a-statistic
title="پیام‌های جدید"
:value="faNumber(data?.newMessages ?? 0)"
:value-style="data?.newMessages ? { color: '#ff5a00' } : undefined"
/>
<a-spin :spinning="loading">
<!-- 1. Top Statistics Row -->
<div class="grid grid-cols-2 gap-3 lg:gap-4 lg:grid-cols-5">
<RouterLink :to="{ name: 'products' }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="کل محصولات" :value="faNumber(data?.products ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-orange-50 text-orange-600">
<VuesaxIcon name="bag-2" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
{{ faNumber((data?.products ?? 0) - (data?.draftProducts ?? 0)) }} محصول فعال روی سایت
</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'media' }">
<a-card hoverable><a-statistic title="تصاویر" :value="faNumber(data?.media ?? 0)" /></a-card>
<RouterLink :to="{ name: 'products', query: { status: 'draft' } }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="پیش‌نویس‌ها" :value="faNumber(data?.draftProducts ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-amber-50 text-amber-600">
<VuesaxIcon name="document-text" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">نیاز به بازبینی و انتشار</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'categories' }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="دسته‌بندی‌ها" :value="faNumber(data?.categories ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-blue-50 text-blue-600">
<VuesaxIcon name="category" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">شاخه‌های تجهیزات ایمنی</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'messages', query: { status: 'NEW' } }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic
title="پیام‌های جدید"
:value="faNumber(data?.newMessages ?? 0)"
:value-style="data?.newMessages ? { color: '#ff5a00', fontWeight: 'bold' } : undefined"
/>
<div
class="flex size-10 items-center justify-center rounded-xl"
:class="data?.newMessages ? 'bg-orange-100 text-orange-600 animate-pulse' : 'bg-gray-100 text-gray-500'"
>
<VuesaxIcon name="sms" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
از مجموع {{ faNumber(data?.messageBreakdown?.total ?? 0) }} پیام
</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'media' }" class="max-lg:col-span-2 active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="رسانه‌ها و تصاویر" :value="faNumber(data?.media ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-purple-50 text-purple-600">
<VuesaxIcon name="gallery" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">تصاویر بهینه‌شده WebP</div>
</a-card>
</RouterLink>
</div>
<!-- 2. Quick Actions & Site Health Section -->
<div class="mt-6 grid gap-6 lg:grid-cols-12">
<!-- Quick Action Buttons -->
<a-card title="دسترسی‌های سریع" class="lg:col-span-8">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<RouterLink :to="{ name: 'product-new' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-orange-200 hover:bg-orange-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-orange-600 shadow-sm">
<VuesaxIcon name="add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">افزودن محصول</span>
<span class="mt-1 text-[11px] text-gray-400">کالای ایمنی جدید</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'category-new' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-blue-200 hover:bg-blue-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-blue-600 shadow-sm">
<VuesaxIcon name="folder-add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">دسته‌بندی جدید</span>
<span class="mt-1 text-[11px] text-gray-400">گروه محصولات</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'sections' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-emerald-200 hover:bg-emerald-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-emerald-600 shadow-sm">
<VuesaxIcon name="row-vertical" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">بخش‌های سایت</span>
<span class="mt-1 text-[11px] text-gray-400">نمایش یا مخفی‌سازی</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'media' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-purple-200 hover:bg-purple-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-purple-600 shadow-sm">
<VuesaxIcon name="gallery-add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">آپلود رسانه</span>
<span class="mt-1 text-[11px] text-gray-400">تصاویر و اسناد</span>
</div>
</RouterLink>
</div>
</a-card>
<!-- Section Visibility Health Card -->
<a-card title="وضعیت بخش‌های صفحه اصلی" class="lg:col-span-4">
<template #extra>
<RouterLink :to="{ name: 'sections' }" class="text-xs text-orange-600 hover:underline">
مدیریت
</RouterLink>
</template>
<div class="flex items-center gap-4">
<a-progress
type="circle"
:percent="sectionPercent"
:size="72"
:stroke-color="{ '0%': '#ff8a3d', '100%': '#ff5a00' }"
/>
<div class="space-y-1.5 text-xs">
<div class="font-bold text-gray-800">
{{ faNumber(data?.sectionsSummary?.visible ?? 0) }} بخش از {{ faNumber(data?.sectionsSummary?.total ?? 12) }} فعال
</div>
<div class="text-gray-500">
<span v-if="(data?.sectionsSummary?.hidden ?? 0) === 0" class="text-emerald-600 font-medium">
تمامی بخش‌های سایت در حال نمایش هستند
</span>
<span v-else class="text-amber-600 font-medium">
{{ faNumber(data?.sectionsSummary?.hidden ?? 0) }} بخش غیرفعال / مخفی
</span>
</div>
<div class="text-[11px] text-gray-400">
تغییر وضعیت آنی از منوی بخش‌های سایت
</div>
</div>
</div>
</a-card>
</div>
<!-- 3. Charts Row (نمودارهای تحلیلی) -->
<div class="mt-6 grid gap-6 lg:grid-cols-12">
<!-- Chart 1: Products distribution per category -->
<a-card title="نمودار توزیع محصولات بر اساس دسته‌بندی" class="lg:col-span-7">
<div v-if="!data?.categoryStats?.length" class="py-8">
<a-empty description="داده‌ای موجود نیست" />
</div>
<div v-else class="space-y-3.5 pt-1">
<div v-for="cat in data.categoryStats" :key="cat.id" class="space-y-1">
<div class="flex items-center justify-between text-xs font-medium">
<span class="text-gray-700">{{ cat.name }}</span>
<span class="font-mono text-gray-500">{{ faNumber(cat.count) }} محصول</span>
</div>
<a-progress
:percent="Math.round((cat.count / maxCategoryCount) * 100)"
:show-info="false"
:stroke-color="{ '0%': '#ffd0b5', '100%': '#ff5a00' }"
size="small"
/>
</div>
</div>
</a-card>
<!-- Chart 2: Message Status Breakdown & Trend -->
<a-card title="تحلیل پیام‌های تماس دریافتی" class="lg:col-span-5">
<!-- Message Status Chips -->
<div class="grid grid-cols-3 gap-2 text-center">
<div class="rounded-lg bg-orange-50 p-2.5">
<div class="text-lg font-black text-orange-600">{{ faNumber(data?.messageBreakdown?.new ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">جدید</div>
</div>
<div class="rounded-lg bg-blue-50 p-2.5">
<div class="text-lg font-black text-blue-600">{{ faNumber(data?.messageBreakdown?.read ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">خوانده‌شده</div>
</div>
<div class="rounded-lg bg-gray-50 p-2.5">
<div class="text-lg font-black text-gray-600">{{ faNumber(data?.messageBreakdown?.archived ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">بایگانی</div>
</div>
</div>
<!-- 7-Day Trend Bar Chart -->
<div class="mt-5 border-t border-gray-100 pt-4">
<div class="mb-3 flex items-center justify-between text-xs">
<span class="font-bold text-gray-700">روند پیام‌ها در ۷ روز اخیر</span>
<span class="text-gray-400">نمودار روزانه</span>
</div>
<div class="flex h-28 items-end justify-between gap-1.5 sm:gap-2 pt-4">
<div
v-for="day in data?.trend ?? []"
:key="day.date"
class="flex flex-1 flex-col items-center gap-1"
>
<span v-if="day.messages > 0" class="text-[9px] font-bold text-orange-600 mb-0.5">
{{ faNumber(day.messages) }}
</span>
<span v-else class="text-[9px] text-transparent mb-0.5 select-none">-</span>
<a-tooltip :title="`${day.dayName}: ${faNumber(day.messages)} پیام`">
<div class="relative flex w-full justify-center">
<div
class="w-full max-w-[24px] rounded-t-md transition-all duration-300"
:class="day.messages > 0 ? 'bg-orange-500 hover:bg-orange-600' : 'bg-gray-100'"
:style="{
height: `${Math.max(Math.round((day.messages / maxTrendCount) * 72), 6)}px`,
}"
/>
</div>
</a-tooltip>
<span class="text-[10px] text-gray-400 truncate max-w-[36px]">{{ day.dayName }}</span>
</div>
</div>
</div>
</a-card>
</div>
<!-- 4. Tables / Activity Lists Row -->
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<!-- Recent Messages -->
<a-card title="آخرین پیام‌های تماس">
<template #extra><RouterLink :to="{ name: 'messages' }">همه پیام‌ها</RouterLink></template>
<template #extra>
<RouterLink :to="{ name: 'messages' }" class="text-xs text-orange-600 hover:underline">
مشاهده همه ←
</RouterLink>
</template>
<a-empty v-if="!data?.recentMessages.length" description="پیامی ثبت نشده است" />
<a-list v-else :data-source="data.recentMessages" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="`${item.name} — ${item.org}`" :description="item.message || item.phone" />
<a-list-item-meta
:title="`${item.name} — ${item.org}`"
:description="item.message || item.phone"
/>
<div class="text-left text-xs text-gray-400">
<a-tag :color="item.status === 'NEW' ? 'orange' : 'default'">{{ MESSAGE_STATUS_LABELS[item.status as keyof typeof MESSAGE_STATUS_LABELS] }}</a-tag>
<div class="mt-1">{{ jalali(item.createdAt) }}</div>
<a-tag :color="item.status === 'NEW' ? 'orange' : item.status === 'READ' ? 'blue' : 'default'">
{{ MESSAGE_STATUS_LABELS[item.status as keyof typeof MESSAGE_STATUS_LABELS] }}
</a-tag>
<div class="mt-1 font-mono text-[11px]">{{ jalali(item.createdAt) }}</div>
</div>
</a-list-item>
</template>
</a-list>
</a-card>
<a-card title="آخرین فعالیت‌ها">
<!-- Recent Audit Log / Activity -->
<a-card title="آخرین فعالیت‌ها و رویدادهای سیستم">
<a-empty v-if="!data?.recentActivity.length" description="فعالیتی ثبت نشده است" />
<a-timeline v-else class="!mt-2">
<a-timeline-item v-for="a in data.recentActivity" :key="a.id">
<a-timeline v-else class="!mt-2 max-h-[360px] overflow-y-auto pe-2">
<a-timeline-item
v-for="a in data.recentActivity"
:key="a.id"
:color="a.action === 'delete' ? 'red' : a.action === 'create' ? 'green' : 'blue'"
>
<div class="text-sm">
<b>{{ activityLabel(a.action, a.entity) }}</b>
<span v-if="a.summary" class="text-gray-600"> — {{ a.summary }}</span>
</div>
<div class="text-xs text-gray-400">{{ a.user ?? "سیستم" }} · {{ jalali(a.createdAt) }}</div>
<div class="mt-0.5 text-xs text-gray-400">
{{ a.user ?? "سیستم" }} · {{ jalali(a.createdAt) }}
</div>
</a-timeline-item>
</a-timeline>
</a-card>
+24 -1
View File
@@ -176,6 +176,10 @@ async function submit() {
}
}
.login-page {
color-scheme: dark;
}
.login-panel :deep(.ant-form-item-label > label) {
color: #edede6 !important;
}
@@ -187,6 +191,12 @@ async function submit() {
color: #edede6 !important;
}
.login-panel :deep(.ant-input-affix-wrapper input.ant-input),
.login-panel :deep(.ant-input-affix-wrapper input) {
background: transparent !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) {
@@ -198,10 +208,23 @@ async function submit() {
color: #8e9ab0 !important;
}
.login-panel :deep(.ant-input-password-icon) {
.login-panel :deep(.ant-input-password-icon),
.login-panel :deep(.ant-input-suffix) {
color: #8e9ab0 !important;
}
/* Fix browser autofill white background and black text */
.login-panel :deep(input:-webkit-autofill),
.login-panel :deep(input:-webkit-autofill:hover),
.login-panel :deep(input:-webkit-autofill:focus),
.login-panel :deep(input:-webkit-autofill:active) {
-webkit-box-shadow: 0 0 0 1000px #152038 inset !important;
box-shadow: 0 0 0 1000px #152038 inset !important;
-webkit-text-fill-color: #edede6 !important;
caret-color: #edede6 !important;
transition: background-color 99999s ease-in-out 0s;
}
.login-panel :deep(.ant-form-item-explain-error) {
color: #ff7a2a;
}
+110 -21
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { onMounted, onUnmounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { faNumber, fileSize, jalali } from "@/utils/format";
const auth = useAuthStore();
const items = ref<MediaDto[]>([]);
const total = ref(0);
const page = ref(1);
@@ -16,6 +18,13 @@ const uploading = ref(0);
const selected = ref<MediaDto | null>(null);
const altDraft = ref("");
const PAGE_SIZE = 36;
const isMobile = ref(false);
function updateMobile() {
if (typeof window !== "undefined") {
isMobile.value = window.innerWidth < 1024;
}
}
async function load() {
loading.value = true;
@@ -82,7 +91,17 @@ async function copyUrl(url: string) {
void message.success("آدرس کپی شد");
}
onMounted(load);
onMounted(() => {
updateMobile();
window.addEventListener("resize", updateMobile);
void load();
});
onUnmounted(() => {
if (typeof window !== "undefined") {
window.removeEventListener("resize", updateMobile);
}
});
</script>
<template>
@@ -100,51 +119,121 @@ onMounted(load);
v-model:value="q"
allow-clear
placeholder="جستجو در نام فایل یا متن جایگزین"
class="mb-4 max-w-md"
class="mb-4 w-full md:max-w-md"
@search="() => { page = 1; load(); }"
/>
<a-spin :spinning="loading">
<a-empty v-if="!items.length && !loading" description="تصویری پیدا نشد" />
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-9">
<div class="grid grid-cols-3 gap-2.5 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-9">
<button
v-for="m in items"
:key="m.id"
type="button"
class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50 transition hover:border-[#ff5a00]"
class="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition active:scale-95 hover:border-[#ff5a00] focus:outline-none"
:title="m.originalName"
@click="open(m)"
>
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain" loading="lazy" />
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain p-1" loading="lazy" />
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-1 text-[9px] text-white opacity-0 transition group-hover:opacity-100 max-lg:hidden truncate">
{{ m.originalName }}
</div>
</button>
</div>
</a-spin>
<div class="mt-4 flex justify-center">
<a-pagination v-model:current="page" :total="total" :page-size="PAGE_SIZE" :show-size-changer="false" @change="load" />
<a-pagination
v-model:current="page"
:total="total"
:page-size="PAGE_SIZE"
:show-size-changer="false"
:simple="isMobile"
@change="load"
/>
</div>
</a-card>
<a-drawer :open="!!selected" title="جزئیات تصویر" :width="380" placement="left" @close="selected = null">
<!-- Mobile Floating Upload Button -->
<div class="lg:hidden fixed bottom-20 left-4 z-30">
<a-upload accept="image/jpeg,image/png,image/webp,image/avif,image/gif" multiple :show-upload-list="false" :before-upload="upload">
<button
type="button"
class="flex size-13 items-center justify-center rounded-full bg-[#ff5a00] text-white shadow-lg shadow-orange-500/30 transition-transform active:scale-95"
title="آپلود تصویر"
:disabled="uploading > 0"
>
<VuesaxIcon v-if="uploading === 0" name="document-upload" :size="24" />
<span v-else class="text-xs font-bold">{{ uploading }}</span>
</button>
</a-upload>
</div>
<!-- Image Detail Drawer (Side on Desktop, Bottom Sheet on Mobile) -->
<a-drawer
:open="!!selected"
title="جزئیات تصویر"
:width="isMobile ? '100%' : 380"
:height="isMobile ? '82vh' : undefined"
:placement="isMobile ? 'bottom' : 'left'"
:body-style="{ paddingBottom: isMobile ? '80px' : '24px' }"
@close="selected = null"
>
<template v-if="selected">
<img :src="selected.url" :alt="selected.alt" class="w-full rounded-lg border border-gray-200 bg-gray-50 object-contain" />
<a-descriptions :column="1" size="small" class="mt-4">
<a-descriptions-item label="نام فایل"><span dir="ltr">{{ selected.originalName }}</span></a-descriptions-item>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 flex items-center justify-center p-2">
<img :src="selected.url" :alt="selected.alt" class="max-h-60 w-full object-contain" />
</div>
<a-descriptions :column="isMobile ? 2 : 1" size="small" class="mt-4">
<a-descriptions-item label="نام فایل" :span="isMobile ? 2 : 1">
<span dir="ltr" class="truncate block max-w-[240px] text-xs font-mono text-gray-700">{{ selected.originalName }}</span>
</a-descriptions-item>
<a-descriptions-item label="ابعاد">
<span dir="ltr">{{ selected.width }}×{{ selected.height }}</span>
<span dir="ltr" class="text-xs font-mono">{{ selected.width }}×{{ selected.height }}</span>
</a-descriptions-item>
<a-descriptions-item label="حجم">{{ fileSize(selected.size) }}</a-descriptions-item>
<a-descriptions-item label="تاریخ">{{ jalali(selected.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="تاریخ" :span="isMobile ? 2 : 1">{{ jalali(selected.createdAt) }}</a-descriptions-item>
</a-descriptions>
<a-form layout="vertical" class="mt-4">
<a-form-item label="متن جایگزین (alt)">
<a-input v-model:value="altDraft" />
<a-form-item label="متن جایگزین (alt)" class="!mb-3">
<a-input v-model:value="altDraft" placeholder="توضیح کوتاه برای سئو و دسترس‌پذیری" />
</a-form-item>
<a-space wrap>
<a-button type="primary" @click="saveAlt">ذخیره</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><VuesaxIcon name="trash" :size="16" /></template>حذف</a-button>
<!-- Actions: Desktop row vs Mobile full touch grid -->
<div class="max-lg:grid max-lg:grid-cols-2 max-lg:gap-2 max-lg:mt-4 max-lg:hidden">
<a-space wrap>
<a-button type="primary" @click="saveAlt">ذخیره</a-button>
<a-button @click="copyUrl(selected.url)"><template #icon><VuesaxIcon name="copy" :size="16" /></template>کپی آدرس</a-button>
<a-popconfirm
v-if="auth.isAdmin"
title="این تصویر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove"
>
<a-button danger><template #icon><VuesaxIcon name="trash" :size="16" /></template>حذف</a-button>
</a-popconfirm>
</a-space>
</div>
<div class="lg:hidden grid grid-cols-2 gap-2 mt-4">
<a-button type="primary" class="h-10 !font-bold" @click="saveAlt">ذخیره متن</a-button>
<a-button class="h-10 !flex !items-center !justify-center !gap-1" @click="copyUrl(selected.url)">
<template #icon><VuesaxIcon name="copy" :size="16" /></template>
<span>کپی آدرس</span>
</a-button>
<a-popconfirm
v-if="auth.isAdmin"
title="این تصویر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove"
>
<a-button danger class="col-span-2 h-10 !flex !items-center !justify-center !gap-1">
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
<span>حذف تصویر</span>
</a-button>
</a-popconfirm>
</a-space>
</div>
</a-form>
</template>
</a-drawer>
+172 -6
View File
@@ -7,13 +7,15 @@ import {
type Paginated,
} from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref, watch } from "vue";
import { computed, 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 { useAuthStore } from "@/stores/auth";
import { faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const route = useRoute();
/** "ALL" or one status */
const status = ref<MessageStatus | "ALL">((route.query.status as MessageStatus) || "ALL");
@@ -23,8 +25,11 @@ const page = ref(1);
const rows = ref<ContactMessageDto[]>([]);
const total = ref(0);
const loading = ref(false);
const expandedIds = ref<number[]>([]);
const PAGE_SIZE = 20;
const totalPages = computed(() => Math.ceil(total.value / PAGE_SIZE) || 1);
async function load() {
loading.value = true;
try {
@@ -44,11 +49,24 @@ async function setStatus(row: ContactMessageDto, s: MessageStatus) {
try {
await api(`/admin/messages/${row.id}`, { method: "PATCH", body: { status: s } });
row.status = s;
void message.success(`وضعیت پیام به «${MESSAGE_STATUS_LABELS[s]}» تغییر کرد`);
} catch (e) {
void message.error(errorMessage(e));
}
}
function toggleExpand(row: ContactMessageDto) {
const idx = expandedIds.value.indexOf(row.id);
if (idx > -1) {
expandedIds.value.splice(idx, 1);
} else {
expandedIds.value.push(row.id);
if (row.status === "NEW") {
void setStatus(row, "READ");
}
}
}
/** opening a new message marks it as read */
function onExpand(expanded: boolean, row: ContactMessageDto) {
if (expanded && row.status === "NEW") void setStatus(row, "READ");
@@ -64,6 +82,12 @@ async function remove(row: ContactMessageDto) {
}
}
function onMobilePageChange(p: number) {
if (p < 1 || p > totalPages.value) return;
page.value = p;
void load();
}
const STATUS_COLORS: Record<MessageStatus, string> = { NEW: "orange", READ: "blue", ARCHIVED: "default" };
const columns = [
{ title: "نام", dataIndex: "name" },
@@ -82,11 +106,28 @@ onMounted(load);
</script>
<template>
<PageHeader title="پیام‌های تماس" :subtitle="`${faNumber(total)} پیام — درخواست‌های ثبت‌شده از فرم مشاوره سایت`">
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
<PageHeader title="پیام‌های تماس" :subtitle="`${faNumber(total)} پیام — استعلام‌ها و درخواست‌های ثبت‌شده از سایت`">
<div class="!hidden lg:!block">
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
</div>
</PageHeader>
<a-card :body-style="{ padding: 0 }">
<!-- Mobile Segmented Filter Row (Mobile Only) -->
<div class="mobile-filter-bar sticky top-[52px] z-20 bg-white/95 backdrop-blur-md -mx-3 px-3 py-2 border-b border-slate-100 flex items-center gap-1.5 overflow-x-auto no-scrollbar mb-3 lg:!hidden">
<button
v-for="opt in FILTER_OPTIONS"
:key="opt.value"
type="button"
class="px-3 py-1.5 rounded-full text-xs font-bold transition shrink-0 select-none cursor-pointer"
:class="status === opt.value ? 'bg-orange-600 text-white shadow-xs' : 'bg-slate-100 text-slate-600 active:bg-slate-200'"
@click="status = opt.value as any"
>
{{ opt.label }}
</button>
</div>
<!-- Desktop Table View (lg and up) -->
<a-card class="!hidden lg:!block" :body-style="{ padding: 0 }">
<a-table
:columns="columns"
:data-source="rows"
@@ -101,7 +142,7 @@ onMounted(load);
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'phone'">
<a :href="`tel:${record.phone}`" dir="ltr">{{ record.phone }}</a>
<a :href="`tel:${record.phone}`" dir="ltr" class="hover:text-orange-600 font-mono">{{ record.phone }}</a>
</template>
<template v-else-if="column.key === 'date'">{{ jalali(record.createdAt) }}</template>
<template v-else-if="column.key === 'status'">
@@ -116,11 +157,136 @@ onMounted(load);
</a-select>
</template>
<template v-else-if="column.key === 'actions'">
<a-popconfirm title="این پیام حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record as ContactMessageDto)">
<a-popconfirm
v-if="auth.isAdmin"
title="این پیام حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as ContactMessageDto)"
>
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
<!-- Mobile Message Cards View (Mobile Only) -->
<div class="mobile-messages-view block lg:!hidden pb-20">
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="پیامی با این وضعیت موجود نیست" class="py-12" />
<div v-else class="space-y-3">
<div
v-for="record in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3.5 shadow-2xs transition"
:class="{ 'border-orange-300 ring-2 ring-orange-100': record.status === 'NEW' }"
>
<!-- Top Row: Sender Info + Status Pill -->
<div class="flex items-start justify-between gap-2">
<div>
<div class="flex items-center gap-1.5">
<span class="font-bold text-[14px] text-slate-800">{{ record.name }}</span>
<span v-if="record.status === 'NEW'" class="size-2 rounded-full bg-orange-500 animate-pulse" />
</div>
<div v-if="record.org" class="text-xs text-slate-500 font-medium mt-0.5">
{{ record.org }}
</div>
</div>
<a-tag :color="STATUS_COLORS[record.status as MessageStatus]" class="!m-0 !text-[11px] !font-bold">
{{ MESSAGE_STATUS_LABELS[record.status as keyof typeof MESSAGE_STATUS_LABELS] }}
</a-tag>
</div>
<!-- Middle Row: Date & Call Button -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-2.5">
<span class="text-[11px] text-slate-400 font-mono">
{{ jalali(record.createdAt) }}
</span>
<!-- Direct Call Button -->
<a
:href="'tel:' + record.phone"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200/70 font-bold text-xs active:bg-emerald-100 active:scale-95 transition"
title="تماس مستقیم با مشتری"
>
<Phone :size="13" class="text-emerald-600 shrink-0" />
<span dir="ltr" class="font-mono">{{ record.phone }}</span>
</a>
</div>
<!-- Message Body Preview & Full Content -->
<div class="mt-3 cursor-pointer" @click="toggleExpand(record)">
<div
v-if="!expandedIds.includes(record.id)"
class="text-xs text-slate-600 line-clamp-2 leading-relaxed bg-slate-50/70 p-2.5 rounded-lg border border-slate-100"
>
{{ record.message || "— بدون توضیح —" }}
<div class="text-[10px] text-orange-600 font-bold mt-1">مشاهده متن کامل پیام ←</div>
</div>
<div
v-else
class="text-xs text-slate-700 whitespace-pre-line leading-relaxed bg-orange-50/30 p-3 rounded-lg border border-orange-100"
>
{{ record.message || "— بدون توضیح —" }}
<div class="text-[10px] text-slate-400 font-medium mt-2">بستن متن پیام ↑</div>
</div>
</div>
<!-- Footer Actions: Status Changer + Delete -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-3">
<div class="flex items-center gap-2">
<span class="text-[11px] text-slate-400">وضعیت:</span>
<a-select
:value="record.status"
size="small"
class="w-28 !text-xs"
:options="STATUS_OPTIONS"
@change="(v: unknown) => setStatus(record, v as MessageStatus)"
/>
</div>
<a-popconfirm
v-if="auth.isAdmin"
title="این پیام حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 active:bg-rose-200 transition"
title="حذف پیام"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
<!-- Mobile Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between gap-3 pt-4 px-1">
<a-button
:disabled="page <= 1"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(page - 1)"
>
صفحه قبلی
</a-button>
<span class="text-xs text-slate-500 font-mono">
{{ page.toLocaleString("fa-IR") }} / {{ totalPages.toLocaleString("fa-IR") }}
</span>
<a-button
:disabled="page >= totalPages"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(page + 1)"
>
صفحه بعدی
</a-button>
</div>
</a-spin>
</div>
</template>
+145 -49
View File
@@ -59,6 +59,16 @@ const groups = ref<AdminCategoryDetail["groups"]>([]);
const loading = ref(false);
const saving = ref(false);
const mobileTab = ref<"general" | "specs" | "desc" | "media" | "seo">("general");
const MOBILE_TABS = [
{ key: "general", label: "اطلاعات اصلی" },
{ key: "specs", label: "مشخصات فنی" },
{ key: "desc", label: "توضیحات و کاربرد" },
{ key: "media", label: "تصاویر و سفارش" },
{ key: "seo", label: "سئو و انتشار" },
] as const;
const category = computed(() => categories.value.find((c) => c.id === model.value.categoryId));
/** a-select clears to undefined, the API expects null */
const groupId = computed({
@@ -161,25 +171,48 @@ onMounted(load);
<template>
<PageHeader :title="id ? `ویرایش محصول: ${model.name}` : 'محصول جدید'" back="/products">
<a
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><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
<div class="!hidden lg:!flex items-center gap-2">
<a
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><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</div>
</PageHeader>
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="save">
<a-form layout="vertical" class="pb-24 lg:pb-6" @submit.prevent="save">
<!-- Mobile Segmented Navigation Tabs -->
<div
class="mobile-edit-tabs lg:!hidden flex items-center gap-1.5 overflow-x-auto pb-2.5 mb-3 sticky top-[52px] z-20 bg-white/95 backdrop-blur-md -mx-3 px-3 pt-1 border-b border-slate-100 no-scrollbar"
>
<button
v-for="t in MOBILE_TABS"
:key="t.key"
type="button"
class="px-3 py-1.5 rounded-full text-xs font-bold transition shrink-0 select-none cursor-pointer"
:class="
mobileTab === t.key
? 'bg-orange-600 text-white shadow-xs'
: 'bg-slate-100 text-slate-600 active:bg-slate-200'
"
@click="mobileTab = t.key"
>
{{ t.label }}
</button>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<a-card title="اطلاعات محصول">
<!-- Card 1: Main Info -->
<a-card title="اطلاعات محصول" :class="mobileTab !== 'general' && 'max-lg:hidden'">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام محصول" required>
<a-input v-model:value="model.name" />
@@ -206,7 +239,8 @@ onMounted(load);
</div>
</a-card>
<a-card title="توضیحات کامل (صفحه محصول)">
<!-- Card 2: Details Description -->
<a-card title="توضیحات کامل (صفحه محصول)" :class="mobileTab !== 'desc' && 'max-lg:hidden'">
<FormField path="details" hint="برای شروع پاراگراف جدید یک خط خالی بگذارید. اگر خالی بماند، توضیح دسته نمایش داده می‌شود.">
<a-textarea
v-model:value="model.details"
@@ -216,7 +250,8 @@ onMounted(load);
</FormField>
</a-card>
<a-card title="ویژگی‌ها و کاربردها">
<!-- Card 3: Features & Applications -->
<a-card title="ویژگی‌ها و کاربردها" :class="mobileTab !== 'desc' && 'max-lg:hidden'">
<FormField path="features" label="ویژگی‌های کلیدی" hint="هر ویژگی در یک خط">
<LinesInput v-model="model.features" :rows="4" placeholder="مثلاً: زیره ضد لغزش و مقاوم در برابر روغن" />
</FormField>
@@ -225,7 +260,8 @@ onMounted(load);
</FormField>
</a-card>
<a-card title="مشخصات فنی" :body-style="{ background: '#fafafa' }">
<!-- Card 4: Technical Attributes & Specifications -->
<a-card title="مشخصات فنی" :body-style="{ background: '#fafafa' }" :class="mobileTab !== 'specs' && 'max-lg:hidden'">
<p class="mb-4 text-sm text-gray-500">
ردیف‌های جدول مشخصات صفحه محصول (مثلاً «جنس رویه» ← «چرم طبیعی»). کد، دسته، برند و موارد زیر به‌صورت خودکار به جدول اضافه می‌شوند.
</p>
@@ -260,40 +296,19 @@ onMounted(load);
</div>
</a-card>
<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" />
<button
type="button"
class="absolute end-1 top-1 flex size-6 items-center justify-center rounded-full bg-white/90 text-red-500 shadow"
title="حذف از گالری"
@click="removeFromGallery(m.id)"
>
<VuesaxIcon name="close-circle" :size="16" class="text-xs" />
</button>
</div>
<button
type="button"
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"
>
<VuesaxIcon name="add" :size="16" />
</button>
</div>
<MediaPicker v-model:open="galleryPicker" @select="addToGallery" />
</a-card>
</div>
<div class="space-y-6">
<a-card title="انتشار">
<!-- Card: Publishing (Desktop & Mobile SEO tab) -->
<a-card title="انتشار" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "پیش‌نویس (مخفی)" }}</span>
</FormField>
</a-card>
<a-card title="دسته‌بندی">
<!-- Card: Category & Group (General tab on mobile) -->
<a-card title="دسته‌بندی" :class="mobileTab !== 'general' && 'max-lg:hidden'">
<FormField path="categoryId" label="دسته" required>
<a-select
v-model:value="model.categoryId"
@@ -312,12 +327,56 @@ onMounted(load);
/>
</FormField>
</a-card>
<a-card title="تصویر اصلی">
<FormField path="imageId" hint="بدون تصویر، کاور دسته نمایش داده می‌شود">
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
</FormField>
<!-- Card: Product Images (Cover + Gallery) -->
<a-card title="تصاویر محصول" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<!-- Cover / Main Image -->
<div>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-700">تصویر اصلی (کاور)</span>
<span class="text-[11px] text-slate-400">نمایش در لیست‌ها و کارت‌ها</span>
</div>
<FormField path="imageId" hint="در صورت عدم انتخاب، کاور دسته‌بندی استفاده می‌شود">
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
</FormField>
</div>
<div class="my-5 border-t border-slate-100" />
<!-- Gallery Images -->
<div>
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs font-bold text-slate-700">گالری تصاویر بیشتر</span>
<span v-if="gallery.length" class="text-[11px] text-orange-600 font-mono font-bold">{{ gallery.length }} تصویر</span>
</div>
<p class="mb-3 text-xs text-slate-400">تصاویری که در اسلایدر جزئیات محصول نمایش داده می‌شوند.</p>
<div class="flex flex-wrap gap-2.5">
<div v-for="m in gallery" :key="m.id" class="relative size-20 overflow-hidden rounded-lg border border-slate-200 bg-slate-50">
<img :src="m.url" alt="" class="size-full object-contain p-1" />
<button
type="button"
class="absolute end-1 top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-white shadow-xs transition hover:bg-red-600 cursor-pointer"
title="حذف از گالری"
@click="removeFromGallery(m.id)"
>
<VuesaxIcon name="close-circle" :size="12" />
</button>
</div>
<button
type="button"
class="flex size-20 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-slate-300 text-slate-400 hover:border-[#ff5a00] hover:text-[#ff5a00] hover:bg-orange-50/30 transition cursor-pointer"
@click="galleryPicker = true"
>
<VuesaxIcon name="add" :size="18" />
<span class="text-[10px] font-medium">افزودن</span>
</button>
</div>
</div>
<MediaPicker v-model:open="galleryPicker" @select="addToGallery" />
</a-card>
<a-card title="برند و سفارش">
<!-- Card 9: Brand and Ordering (Media tab on mobile) -->
<a-card title="برند و سفارش" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="brand" label="برند">
<a-input v-model:value="model.brand" />
</FormField>
@@ -331,7 +390,9 @@ onMounted(load);
<a-input v-model:value="model.minOrder" placeholder="مثلاً: ۱ کارتن" />
</FormField>
</a-card>
<a-card title="سئو">
<!-- Card 10: SEO (SEO tab on mobile) -->
<a-card title="سئو" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام محصول">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField>
@@ -341,6 +402,41 @@ onMounted(load);
</a-card>
</div>
</div>
<!-- Mobile Sticky Bottom Action Bar -->
<div
class="fixed bottom-0 start-0 end-0 z-30 bg-white/95 backdrop-blur-md border-t border-slate-200/80 p-3 shadow-lg flex items-center justify-between gap-3 lg:!hidden"
>
<div class="flex items-center gap-2">
<a-switch v-model:checked="model.isPublished" size="small" />
<span class="text-xs" :class="model.isPublished ? 'text-emerald-600 font-bold' : 'text-slate-400'">
{{ model.isPublished ? "منتشرشده" : "پیش‌نویس" }}
</span>
</div>
<div class="flex items-center gap-2">
<a
v-if="id && category?.isPublished && model.isPublished"
:href="`${SITE_URL}/products/${model.slug}`"
target="_blank"
rel="noopener"
class="size-10 rounded-xl bg-slate-100 flex items-center justify-center text-slate-600 hover:text-orange-600 active:bg-slate-200"
title="مشاهده در سایت"
>
<VuesaxIcon name="export" :size="18" />
</a>
<a-button
type="primary"
class="!h-10 !px-5 !rounded-xl !font-bold !flex !items-center !gap-1.5 !shadow-sm"
:loading="saving"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره محصول
</a-button>
</div>
</div>
</a-form>
</a-spin>
</template>
+409 -19
View File
@@ -7,9 +7,11 @@ import { api, errorMessage } from "@/api/http";
import type { AdminCategory, AdminCategoryDetail, AdminProduct } from "@/api/types";
import AdminPage from "@/components/AdminPage.vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useAuthStore } from "@/stores/auth";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const tableShell = ref<HTMLElement | null>(null);
const scrollY = ref(360);
let tableResizeObserver: ResizeObserver | null = null;
@@ -44,11 +46,62 @@ const loading = ref(false);
const categories = ref<AdminCategory[]>([]);
const groups = ref<AdminCategoryDetail["groups"]>([]);
const filtersOpen = ref(false);
const mobileFilterDrawer = ref(false);
const activeFilterCount = computed(
() => [filters.categoryId, filters.groupId, filters.status].filter(Boolean).length,
);
const activeCategoryName = computed(
() => categories.value.find((c) => c.id === filters.categoryId)?.name,
);
const activeGroupName = computed(
() => groups.value.find((g) => g.id === filters.groupId)?.name,
);
const totalPages = computed(() => Math.ceil(total.value / filters.pageSize) || 1);
function clearAllFilters() {
filters.categoryId = undefined;
filters.groupId = undefined;
filters.status = undefined;
filters.q = "";
filters.page = 1;
void load();
}
function clearCategoryFilter() {
filters.categoryId = undefined;
filters.groupId = undefined;
filters.page = 1;
void load();
}
function clearGroupFilter() {
filters.groupId = undefined;
filters.page = 1;
void load();
}
function clearStatusFilter() {
filters.status = undefined;
filters.page = 1;
void load();
}
function clearSearch() {
filters.q = "";
filters.page = 1;
void load();
}
function onMobilePageChange(p: number) {
if (p < 1 || p > totalPages.value) return;
filters.page = p;
void load();
}
/** products without their own photo show the category cover (same fallback as the website) */
const thumbOf = (row: AdminProduct) =>
row.image
@@ -219,8 +272,22 @@ onBeforeUnmount(() => {
<template>
<AdminPage lock-body>
<template #head>
<div class="toolbar">
<!-- Desktop Toolbar (lg and up) -->
<div class="toolbar !hidden lg:!flex">
<div class="toolbar__start">
<a-input-search
v-model:value="filters.q"
allow-clear
class="toolbar__search"
placeholder="جستجوی نام یا کد"
@search="
() => {
filters.page = 1;
load();
}
"
/>
<a-popover
v-model:open="filtersOpen"
trigger="click"
@@ -266,19 +333,6 @@ onBeforeUnmount(() => {
</a-button>
</a-badge>
</a-popover>
<a-input-search
v-model:value="filters.q"
allow-clear
class="toolbar__search"
placeholder="جستجوی نام یا کد"
@search="
() => {
filters.page = 1;
load();
}
"
/>
</div>
<RouterLink
@@ -288,15 +342,87 @@ onBeforeUnmount(() => {
query: filters.categoryId ? { categoryId: filters.categoryId } : {},
}"
>
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</div>
<!-- Mobile Toolbar (Mobile Only) -->
<div class="mobile-products-toolbar lg:!hidden flex flex-col gap-2">
<div class="flex items-center gap-2 w-full">
<a-input-search
v-model:value="filters.q"
allow-clear
class="flex-1"
placeholder="جستجوی نام یا کد کالا..."
@search="
() => {
filters.page = 1;
load();
}
"
/>
<a-badge :count="activeFilterCount" :offset="[-2, 2]" color="#ff5a00">
<a-button
class="!h-9 !px-3 !rounded-lg !flex !items-center !gap-1.5"
:type="activeFilterCount > 0 ? 'primary' : 'default'"
:ghost="activeFilterCount > 0"
@click="mobileFilterDrawer = true"
>
<template #icon><VuesaxIcon name="filter" :size="16" /></template>
<span class="text-xs">فیلتر</span>
</a-button>
</a-badge>
</div>
<!-- Active Filter Chips on Mobile -->
<div
v-if="activeFilterCount > 0 || filters.q"
class="flex items-center gap-1.5 overflow-x-auto py-1 text-[11px] no-scrollbar"
>
<span
v-if="filters.q"
class="inline-flex items-center gap-1 bg-slate-100 text-slate-700 px-2 py-0.5 rounded-full shrink-0"
>
جستجو: «{{ filters.q }}»
<button type="button" class="text-slate-400 hover:text-slate-600" @click="clearSearch">✕</button>
</span>
<span
v-if="activeCategoryName"
class="inline-flex items-center gap-1 bg-orange-50 text-orange-700 border border-orange-200/60 px-2 py-0.5 rounded-full shrink-0"
>
{{ activeCategoryName }}
<button type="button" class="text-orange-400 hover:text-orange-700" @click="clearCategoryFilter">✕</button>
</span>
<span
v-if="activeGroupName"
class="inline-flex items-center gap-1 bg-blue-50 text-blue-700 border border-blue-200/60 px-2 py-0.5 rounded-full shrink-0"
>
{{ activeGroupName }}
<button type="button" class="text-blue-400 hover:text-blue-700" @click="clearGroupFilter">✕</button>
</span>
<span
v-if="filters.status"
class="inline-flex items-center gap-1 bg-slate-100 text-slate-700 px-2 py-0.5 rounded-full shrink-0"
>
{{ filters.status === 'published' ? 'منتشرشده' : 'پیش‌نویس / مخفی' }}
<button type="button" class="text-slate-400 hover:text-slate-700" @click="clearStatusFilter">✕</button>
</span>
<button
type="button"
class="text-[10px] text-rose-500 font-medium hover:underline shrink-0 px-1"
@click="clearAllFilters"
>
حذف همه
</button>
</div>
</div>
</template>
<div ref="tableShell" class="table-shell">
<!-- Desktop Table View (lg and up) -->
<div ref="tableShell" class="table-shell !hidden lg:!flex">
<a-table
class="table-shell__table"
:columns="columns"
@@ -366,6 +492,7 @@ onBeforeUnmount(() => {
<template #icon><VuesaxIcon name="copy" :size="14" /></template>
</a-button>
<a-popconfirm
v-if="auth.isAdmin"
title="این محصول حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@@ -380,6 +507,269 @@ onBeforeUnmount(() => {
</template>
</a-table>
</div>
<!-- Mobile Product Cards View (Mobile Only) -->
<div class="mobile-products-view block lg:!hidden pb-24">
<!-- Reorder Info Banner -->
<div
v-if="reorderable"
class="mb-3 p-2.5 rounded-xl bg-orange-50 border border-orange-200/70 text-xs text-orange-800 flex items-center gap-2"
>
<VuesaxIcon name="info-circle" :size="18" class="text-orange-600 shrink-0" />
<span>حالت تغییر ترتیب فعال است. می‌توانید با فلش‌ها ترتیب کالاها را عوض کنید.</span>
</div>
<!-- Count Header -->
<div class="flex items-center justify-between text-xs text-slate-500 px-1 mb-2.5">
<span>{{ total.toLocaleString("fa-IR") }} محصول</span>
<span v-if="!reorderable && totalPages > 1" class="font-mono">
صفحه {{ filters.page.toLocaleString("fa-IR") }} از {{ totalPages.toLocaleString("fa-IR") }}
</span>
</div>
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="محصولی با این مشخصات یافت نشد" class="py-12" />
<div v-else class="space-y-2.5">
<div
v-for="(record, index) in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3 shadow-2xs transition active:bg-slate-50/50"
>
<!-- Top: Thumbnail + Info -->
<div class="flex items-start gap-3">
<template v-if="thumbOf(record).url">
<img
:src="thumbOf(record).url!"
alt=""
:class="[
'size-14 rounded-lg bg-slate-50 object-contain shrink-0 border border-slate-100',
thumbOf(record).fallback && 'opacity-40 grayscale',
]"
/>
</template>
<div v-else class="size-14 rounded-lg bg-slate-100 shrink-0 flex items-center justify-center text-slate-300">
<VuesaxIcon name="gallery" :size="20" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<RouterLink
:to="{ name: 'product-edit', params: { id: record.id } }"
class="font-bold text-[13px] text-slate-800 hover:text-orange-600 line-clamp-2 leading-snug"
>
{{ record.name }}
</RouterLink>
<span class="text-[10px] font-mono text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded shrink-0" dir="ltr">
{{ record.code }}
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-1.5 text-[11px] text-slate-500">
<span class="text-slate-600 font-medium">{{ record.category.name }}</span>
<span v-if="record.group" class="text-slate-400">/</span>
<span v-if="record.group" class="text-slate-500">{{ record.group.name }}</span>
</div>
</div>
</div>
<!-- Bottom: Publish Status Switch + Touch Actions -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-2.5">
<div class="flex items-center gap-2">
<a-switch
:checked="record.isPublished"
size="small"
@change="(v: unknown) => togglePublish(record, !!v)"
/>
<span class="text-xs" :class="record.isPublished ? 'text-emerald-600 font-medium' : 'text-slate-400'">
{{ record.isPublished ? "منتشرشده" : "پیش‌نویس" }}
</span>
</div>
<div class="flex items-center gap-1.5">
<!-- Reorder buttons when reorderable -->
<template v-if="reorderable">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === 0"
@click="move(index, -1)"
>
<VuesaxIcon name="arrow-up" :size="16" />
</button>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === rows.length - 1"
@click="move(index, 1)"
>
<VuesaxIcon name="arrow-down" :size="16" />
</button>
</template>
<!-- Edit Action -->
<RouterLink :to="{ name: 'product-edit', params: { id: record.id } }">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="ویرایش"
>
<VuesaxIcon name="edit-2" :size="16" />
</button>
</RouterLink>
<!-- Duplicate Action -->
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="تکثیر / کپی"
@click="duplicate(record)"
>
<VuesaxIcon name="copy" :size="16" />
</button>
<!-- Delete Action -->
<a-popconfirm
v-if="auth.isAdmin"
title="این محصول حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 active:bg-rose-200 transition"
title="حذف"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
</div>
<!-- Mobile Pagination -->
<div v-if="!reorderable && totalPages > 1" class="flex items-center justify-between gap-3 pt-4 px-1">
<a-button
:disabled="filters.page <= 1"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(filters.page - 1)"
>
صفحه قبلی
</a-button>
<span class="text-xs text-slate-500 font-mono">
{{ filters.page.toLocaleString("fa-IR") }} / {{ totalPages.toLocaleString("fa-IR") }}
</span>
<a-button
:disabled="filters.page >= totalPages"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(filters.page + 1)"
>
صفحه بعدی
</a-button>
</div>
</a-spin>
<!-- Mobile Floating Action Button (FAB) for New Product -->
<RouterLink
:to="{
name: 'product-new',
query: filters.categoryId ? { categoryId: filters.categoryId } : {},
}"
class="fixed start-4 bottom-[72px] z-30 flex items-center gap-2 px-4 py-2.5 rounded-full bg-linear-to-r from-[#ff5a00] to-[#ff7a29] text-white font-bold text-xs shadow-lg shadow-orange-500/30 active:scale-95 transition"
>
<VuesaxIcon name="add" :size="18" />
<span>محصول جدید</span>
</RouterLink>
</div>
<!-- Mobile Filter Bottom Sheet Drawer -->
<a-drawer
v-model:open="mobileFilterDrawer"
placement="bottom"
height="auto"
:closable="false"
class="mobile-filter-drawer"
:body-style="{ padding: '16px 20px 24px', background: '#ffffff', borderTopLeftRadius: '20px', borderTopRightRadius: '20px' }"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between border-b border-slate-100 pb-3">
<div class="flex items-center gap-2">
<VuesaxIcon name="filter" :size="18" class="text-orange-600" />
<span class="text-sm font-bold text-slate-800">فیلترهای کاتالوگ</span>
</div>
<button
type="button"
class="text-xs text-slate-400 hover:text-slate-600"
@click="mobileFilterDrawer = false"
>
بستن
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">دسته‌بندی</label>
<a-select
v-model:value="filters.categoryId"
allow-clear
size="large"
style="width: 100%"
placeholder="همه دسته‌ها"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">زیرگروه کالا</label>
<a-select
v-model:value="filters.groupId"
allow-clear
size="large"
style="width: 100%"
placeholder="همه زیرگروه‌ها"
:disabled="!groups.length"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">وضعیت انتشار</label>
<a-select
v-model:value="filters.status"
allow-clear
size="large"
style="width: 100%"
placeholder="همه وضعیت‌ها"
:options="[
{ value: 'published', label: 'منتشرشده در سایت' },
{ value: 'draft', label: 'پیش‌نویس / مخفی' },
]"
/>
</div>
</div>
<div class="flex items-center gap-2.5 pt-2 border-t border-slate-100">
<a-button
v-if="activeFilterCount > 0"
danger
class="!h-11 !rounded-xl"
@click="clearAllFilters(); mobileFilterDrawer = false;"
>
حذف فیلترها
</a-button>
<a-button
type="primary"
block
class="!h-11 !rounded-xl !font-bold"
@click="mobileFilterDrawer = false; load();"
>
اعمال فیلترها
</a-button>
</div>
</div>
</a-drawer>
</AdminPage>
</template>
+28 -9
View File
@@ -32,24 +32,43 @@ async function save() {
</script>
<template>
<PageHeader title="حساب کاربری" />
<div class="grid max-w-4xl gap-6 md:grid-cols-2">
<a-card title="مشخصات">
<a-descriptions :column="1">
<PageHeader title="حساب کاربری" subtitle="مشخصات فردی، نقش دسترسی و تغییر رمز عبور ورود به سامانه" />
<div class="grid max-w-4xl gap-4 md:gap-6 md:grid-cols-2">
<!-- User Identity Card -->
<a-card title="مشخصات کاربری">
<div class="flex items-center gap-3.5 mb-4 pb-4 border-b border-gray-100">
<div class="flex size-14 items-center justify-center rounded-2xl bg-gradient-to-tr from-[#ff5a00] to-amber-500 text-xl font-black text-white shadow-md shadow-orange-500/20">
{{ auth.user?.name?.slice(0, 1) || 'U' }}
</div>
<div>
<div class="text-base font-bold text-gray-900">{{ auth.user?.name }}</div>
<div class="mt-0.5 text-xs font-mono text-gray-500" dir="ltr">{{ auth.user?.phone }}</div>
</div>
</div>
<a-descriptions :column="1" size="small">
<a-descriptions-item label="نام">{{ auth.user?.name }}</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-item label="موبایل"><span dir="ltr" class="font-mono">{{ auth.user?.phone }}</span></a-descriptions-item>
<a-descriptions-item label="نقش دسترسی">
<a-tag :color="auth.user?.role === 'ADMIN' ? 'orange' : 'blue'">
{{ auth.user && ROLE_LABELS[auth.user.role] }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- Change Password Card -->
<a-card title="تغییر رمز عبور">
<a-form layout="vertical" @submit.prevent="save">
<FormField path="currentPassword" label="رمز فعلی" required>
<a-input-password v-model:value="model.currentPassword" class="ltr-input" autocomplete="current-password" />
<a-input-password v-model:value="model.currentPassword" class="ltr-input h-10" autocomplete="current-password" />
</FormField>
<FormField path="newPassword" label="رمز جدید" hint="حداقل ۸ کاراکتر" required>
<a-input-password v-model:value="model.newPassword" class="ltr-input" autocomplete="new-password" />
<a-input-password v-model:value="model.newPassword" class="ltr-input h-10" autocomplete="new-password" />
</FormField>
<a-button type="primary" html-type="submit" :loading="saving">تغییر رمز</a-button>
<a-button type="primary" html-type="submit" :loading="saving" class="w-full md:w-auto h-10 !font-bold">
تغییر رمز ورود
</a-button>
</a-form>
</a-card>
</div>
+102 -4
View File
@@ -8,6 +8,7 @@ import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { SectionResponse } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
import { jalali, SITE_URL } from "@/utils/format";
import AboutForm from "@/sections/AboutForm.vue";
import ContactForm from "@/sections/ContactForm.vue";
@@ -37,6 +38,7 @@ const FORMS: Record<SectionKey, Component> = {
footer: FooterForm,
};
const auth = useAuthStore();
const route = useRoute();
const key = String(route.params.key);
const valid = isSectionKey(key);
@@ -55,7 +57,11 @@ const label = computed(() => (valid ? SECTION_LABELS[sectionKey] : null));
async function load() {
try {
const res = await api<SectionResponse>(`/admin/sections/${sectionKey}`);
reset(res.data as never);
const data = res.data as any;
if (sectionKey !== "settings" && data.enabled === undefined) {
data.enabled = true;
}
reset(data as never);
updatedAt.value = res.updatedAt;
loaded.value = true;
} catch (e) {
@@ -79,6 +85,33 @@ async function save() {
}
}
const togglingVisibility = ref(false);
async function onVisibilityChange(val: any) {
const nextVal = Boolean(val);
(model.value as any).enabled = nextVal;
togglingVisibility.value = true;
try {
const res = await api<{ key: string; enabled: boolean; updatedAt: string }>(
`/admin/sections/${sectionKey}/visibility`,
{
method: "PATCH",
body: { enabled: nextVal },
},
);
(model.value as any).enabled = res.enabled !== false;
updatedAt.value = res.updatedAt;
void message.success(
`وضعیت نمایش به ${nextVal ? "نمایش" : "عدم نمایش"} تغییر یافت`,
);
} catch (e) {
(model.value as any).enabled = !nextVal;
void message.error(errorMessage(e));
} finally {
togglingVisibility.value = false;
}
}
if (valid) void load();
</script>
@@ -90,16 +123,81 @@ if (valid) void load();
:subtitle="updatedAt ? `آخرین ذخیره: ${jalali(updatedAt)}` : label!.desc"
back="/sections"
>
<a :href="SITE_URL" target="_blank" rel="noopener"><a-button>مشاهده سایت</a-button></a>
<a-button type="primary" :loading="saving" :disabled="!loaded" @click="save">
<div v-if="sectionKey !== 'settings'" class="max-lg:hidden flex items-center gap-2 me-2">
<span class="text-sm font-medium text-gray-700">نمایش در سایت:</span>
<a-switch
:checked="(model as any)?.enabled !== false"
:loading="togglingVisibility"
checked-children="نمایش"
un-checked-children="مخفی"
@change="onVisibilityChange"
/>
</div>
<a :href="SITE_URL" target="_blank" rel="noopener" class="max-lg:hidden"><a-button>مشاهده سایت</a-button></a>
<a-button
type="primary"
class="max-lg:hidden"
:loading="saving"
:disabled="!loaded || (sectionKey === 'settings' && !auth.isAdmin)"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</PageHeader>
<a-spin :spinning="!loaded">
<a-form v-if="loaded" layout="vertical" @submit.prevent="save">
<a-alert
v-if="loaded && sectionKey === 'settings' && !auth.isAdmin"
type="error"
show-icon
message="فقط مدیر کل می‌تواند تنظیمات سایت را تغییر دهد."
class="mb-4"
/>
<a-alert
v-else-if="loaded && sectionKey !== 'settings' && (model as any).enabled === false"
type="warning"
show-icon
message="این بخش در حال حاضر در سایت نمایش داده نمی‌شود (مخفی است)."
class="mb-4"
/>
<a-form v-if="loaded" layout="vertical" class="pb-20 lg:pb-0" @submit.prevent="save">
<component :is="FORMS[sectionKey]" v-model="model" />
</a-form>
</a-spin>
<!-- Mobile Sticky Bottom Action Bar -->
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-2.5 backdrop-blur-md shadow-lg flex items-center justify-between gap-3">
<div v-if="sectionKey !== 'settings'" class="flex items-center gap-2">
<a-switch
:checked="(model as any)?.enabled !== false"
:loading="togglingVisibility"
size="small"
@change="onVisibilityChange"
/>
<span class="text-xs font-medium text-gray-700">
{{ (model as any)?.enabled !== false ? 'نمایش در سایت' : 'مخفی' }}
</span>
</div>
<div v-else class="text-xs text-gray-500 font-medium">تنظیمات پایه</div>
<div class="flex items-center gap-2">
<a :href="SITE_URL" target="_blank" rel="noopener">
<a-button size="middle" class="!px-3" title="مشاهده سایت">
<template #icon><VuesaxIcon name="eye" :size="16" /></template>
</a-button>
</a>
<a-button
type="primary"
size="middle"
class="!font-bold !px-5"
:loading="saving"
:disabled="!loaded || (sectionKey === 'settings' && !auth.isAdmin)"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره تغییرات
</a-button>
</div>
</div>
</template>
</template>
+144 -10
View File
@@ -1,24 +1,158 @@
<script setup lang="ts">
import { SECTION_KEYS, SECTION_LABELS } from "@imen/shared";
import { SECTION_KEYS, SECTION_LABELS, type SectionKey } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { api, errorMessage } from "@/api/http";
import type { AdminSectionSummary } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { jalali } from "@/utils/format";
const router = useRouter();
const auth = useAuthStore();
// site-wide settings are editable by admins only
const keys = SECTION_KEYS.filter((k) => k !== "settings" || auth.isAdmin);
const loading = ref(true);
const sectionEnabled = reactive<Record<string, boolean>>({
hero: true,
tape: true,
stats: true,
productsIntro: true,
about: true,
services: true,
standards: true,
industries: true,
testimonials: true,
contact: true,
footer: true,
});
const sectionUpdatedAt = reactive<Record<string, string | null>>({});
const toggling = reactive<Record<string, boolean>>({});
function isEnabled(key: SectionKey): boolean {
return sectionEnabled[key] !== false;
}
function getUpdatedAt(key: SectionKey): string | null {
return sectionUpdatedAt[key] ?? null;
}
async function fetchSections() {
loading.value = true;
try {
const list = await api<AdminSectionSummary[]>("/admin/sections");
for (const item of list) {
sectionEnabled[item.key] = item.enabled !== false;
sectionUpdatedAt[item.key] = item.updatedAt;
}
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
async function toggle(key: SectionKey, nextVal: boolean) {
if (key === "settings") return;
const prevVal = sectionEnabled[key] !== false;
sectionEnabled[key] = nextVal;
toggling[key] = true;
try {
const res = await api<{ key: string; enabled: boolean; updatedAt: string }>(
`/admin/sections/${key}/visibility`,
{
method: "PATCH",
body: { enabled: nextVal },
},
);
sectionEnabled[key] = res.enabled !== false;
sectionUpdatedAt[key] = res.updatedAt;
void message.success(
`وضعیت «${SECTION_LABELS[key].title}» به ${nextVal ? "نمایش" : "عدم نمایش"} تغییر یافت`,
);
} catch (e) {
sectionEnabled[key] = prevVal;
void message.error(errorMessage(e));
} finally {
toggling[key] = false;
}
}
function goToEdit(key: SectionKey) {
void router.push({ name: "section-edit", params: { key } });
}
onMounted(() => {
void fetchSections();
});
</script>
<template>
<PageHeader
title="بخش‌های سایت"
subtitle="متن‌ها، تصاویر و فهرست‌های صفحه اصلی؛ تغییرات پس از ذخیره حداکثر ظرف یک دقیقه روی سایت دیده می‌شوند"
subtitle="متن‌ها، تصاویر و فهرست‌های صفحه اصلی؛ تنظیم وضعیت نمایش و ویرایش محتوا"
/>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<RouterLink v-for="key in keys" :key="key" :to="{ name: 'section-edit', params: { key } }">
<a-card hoverable class="h-full">
<div class="font-bold text-gray-800">{{ SECTION_LABELS[key].title }}</div>
<p class="mt-2 text-sm leading-6 text-gray-500">{{ SECTION_LABELS[key].desc }}</p>
</a-card>
</RouterLink>
</div>
<a-spin :spinning="loading">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="key in keys"
:key="key"
class="block group"
>
<a-card
hoverable
class="h-full flex flex-col justify-between transition-all"
:class="{ 'opacity-75 bg-gray-50/60': key !== 'settings' && !isEnabled(key) }"
>
<div>
<div class="flex items-center justify-between gap-2">
<div
class="flex items-center gap-2 cursor-pointer flex-1"
@click="goToEdit(key)"
>
<span class="font-bold text-gray-800 group-hover:text-orange-600 transition-colors">
{{ SECTION_LABELS[key].title }}
</span>
<a-tag v-if="key === 'settings'" color="blue">تنظیمات پایه</a-tag>
<a-tag v-else-if="!isEnabled(key)" color="error">مخفی</a-tag>
</div>
<!-- Switch toggle: standalone button without any outer anchor tag -->
<div
v-if="key !== 'settings'"
class="flex items-center flex-shrink-0"
>
<a-switch
:checked="isEnabled(key)"
:loading="toggling[key]"
checked-children="نمایش"
un-checked-children="مخفی"
@change="(val: any) => toggle(key, Boolean(val))"
/>
</div>
</div>
<p
class="mt-2 text-sm leading-6 text-gray-500 cursor-pointer"
@click="goToEdit(key)"
>
{{ SECTION_LABELS[key].desc }}
</p>
</div>
<div
class="mt-4 flex items-center justify-between border-t border-gray-100 pt-3 text-xs text-gray-400 cursor-pointer"
@click="goToEdit(key)"
>
<span class="text-primary font-medium group-hover:underline">ویرایش بخش ←</span>
<span v-if="getUpdatedAt(key)">آخرین تغییر: {{ jalali(getUpdatedAt(key)) }}</span>
</div>
</a-card>
</div>
</div>
</a-spin>
</template>
+154 -1
View File
@@ -110,7 +110,8 @@ onMounted(load);
</a-button>
</PageHeader>
<a-card :body-style="{ padding: 0 }">
<!-- Desktop Table (Preserved for lg+) -->
<a-card :body-style="{ padding: 0 }" class="max-lg:hidden">
<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 === 'phone'"><span dir="ltr">{{ record.phone }}</span></template>
@@ -139,6 +140,155 @@ onMounted(load);
</a-table>
</a-card>
<!-- Mobile User Cards (App Experience for <lg) -->
<div class="lg:hidden space-y-3">
<a-spin :spinning="loading">
<div v-if="!rows.length && !loading" class="rounded-xl border border-dashed border-gray-200 bg-white p-8 text-center">
<div class="text-sm text-gray-500">کاربری یافت نشد</div>
</div>
<div v-else class="space-y-3">
<div
v-for="user in rows"
:key="user.id"
class="rounded-xl border border-gray-100 bg-white p-4 shadow-sm transition-all"
>
<!-- Header: User Avatar & Role Tag -->
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3">
<div
class="flex size-11 flex-shrink-0 items-center justify-center rounded-full font-bold text-white shadow-sm"
:class="user.role === 'ADMIN' ? 'bg-gradient-to-tr from-orange-600 to-amber-500' : 'bg-gradient-to-tr from-blue-600 to-sky-500'"
>
{{ user.name.slice(0, 1) }}
</div>
<div>
<div class="font-bold text-gray-900">{{ user.name }}</div>
<a
:href="`tel:${user.phone}`"
class="mt-0.5 inline-block text-xs font-mono text-gray-500 hover:text-orange-600"
dir="ltr"
>
{{ user.phone }}
</a>
</div>
</div>
<div class="flex flex-col items-end gap-1">
<a-tag :color="user.role === 'ADMIN' ? 'orange' : 'blue'" class="!m-0 text-[11px]">
{{ ROLE_LABELS[user.role as keyof typeof ROLE_LABELS] }}
</a-tag>
<a-tag :color="user.isActive ? 'green' : 'default'" class="!m-0 text-[10px]">
{{ user.isActive ? "فعال" : "غیرفعال" }}
</a-tag>
</div>
</div>
<!-- Bottom Meta & Actions -->
<div class="mt-3.5 flex items-center justify-between border-t border-gray-100 pt-3 text-xs text-gray-400">
<div class="flex items-center gap-1.5">
<span>آخرین ورود:</span>
<span class="font-medium text-gray-600">{{ jalali(user.lastLoginAt) }}</span>
</div>
<div class="flex items-center gap-1.5">
<a-button size="small" class="!flex !items-center !gap-1" @click="openEdit(user)">
<template #icon><VuesaxIcon name="edit-2" :size="14" /></template>
<span>ویرایش</span>
</a-button>
<a-popconfirm
v-if="user.id !== auth.user?.id"
title="این کاربر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(user)"
>
<a-button size="small" danger class="!px-2">
<template #icon><VuesaxIcon name="trash" :size="14" /></template>
</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</a-spin>
</div>
<!-- Mobile Floating Action Button (FAB) for New User -->
<button
type="button"
class="lg:hidden fixed bottom-20 left-4 z-30 flex size-13 items-center justify-center rounded-full bg-[#ff5a00] text-white shadow-lg shadow-orange-500/30 transition-transform active:scale-95"
title="کاربر جدید"
@click="openNew"
>
<VuesaxIcon name="add" :size="24" />
</button>
<!-- Mobile Collapsible Role Permissions Guide -->
<details class="lg:hidden mt-4 rounded-xl border border-gray-200 bg-white p-3 text-xs shadow-sm">
<summary class="cursor-pointer font-bold text-gray-700 flex items-center justify-between select-none">
<span class="flex items-center gap-1.5">
<VuesaxIcon name="info-circle" :size="16" class="text-orange-500" />
راهنمای نقش‌ها و مجوزهای دسترسی
</span>
<span class="text-gray-400 text-[10px]">لمس برای مشاهده</span>
</summary>
<div class="mt-3 space-y-3 pt-3 border-t border-gray-100">
<div class="rounded-lg border border-orange-200 bg-orange-50/40 p-2.5">
<div class="flex items-center gap-2 font-bold text-orange-700">
<a-tag color="orange">مدیر کل (ADMIN)</a-tag>
<span class="text-[11px]">دسترسی ارشد سیستمی</span>
</div>
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
<li>مدیریت کاربران، نقش‌ها و تنظیمات امنیتی</li>
<li>تنظیمات پایه سایت، سئو، شبکه‌ها و ساعات کاری</li>
<li>حذف داده‌ها (محصولات، دسته‌ها، پیام‌ها، رسانه‌ها)</li>
</ul>
</div>
<div class="rounded-lg border border-blue-200 bg-blue-50/40 p-2.5">
<div class="flex items-center gap-2 font-bold text-blue-700">
<a-tag color="blue">ویرایشگر (EDITOR)</a-tag>
<span class="text-[11px]">دسترسی محتوایی</span>
</div>
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
<li>ایجاد، ویرایش و انتشار محصولات و دسته‌بندی‌ها</li>
<li>ویرایش محتوای بخش‌های صفحه اصلی</li>
<li>آپلود تصاویر در رسانه و مشاهده پیام‌های تماس</li>
</ul>
</div>
</div>
</details>
<!-- Desktop Role Permissions Reference Cards -->
<div class="mt-6 max-lg:hidden grid gap-4 md:grid-cols-2">
<a-card size="small" class="border-orange-200 bg-orange-50/30">
<div class="flex items-center gap-2 font-bold text-orange-700">
<a-tag color="orange">مدیر کل (ADMIN)</a-tag>
<span class="text-xs">دسترسی ارشد سیستمی</span>
</div>
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
<li>مدیریت کاربران، افزودن کاربر و تغییر نقش‌ها و وضعیت حساب</li>
<li>دسترسی کامل به تنظیمات پایه سایت، ساعات کاری، شبکه‌ها و سئو</li>
<li>مجوز حذف محصولات، دسته‌بندی‌ها، رسانه‌ها و پیام‌های تماس</li>
<li>مشاهده لاگ‌های امنیتی و رویدادهای کامل سامانه</li>
</ul>
</a-card>
<a-card size="small" class="border-blue-200 bg-blue-50/30">
<div class="flex items-center gap-2 font-bold text-blue-700">
<a-tag color="blue">ویرایشگر (EDITOR)</a-tag>
<span class="text-xs">دسترسی محتوایی</span>
</div>
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
<li>ایجاد، ویرایش، کپی و انتشار محصولات و گروه‌ها</li>
<li>ویرایش محتوای بخش‌های صفحه اصلی و فعال/مخفی‌سازی نمایش آنها</li>
<li>آپلود و ویرایش اطلاعات رسانه‌ها در کتابخانه رسانه</li>
<li>مشاهده پیام‌های تماس و به‌روزرسانی وضعیت (بدون دسترسی حذف یا مدیریت کاربران)</li>
</ul>
</a-card>
</div>
<a-modal
:open="editing !== null"
:title="isNew ? 'کاربر جدید' : 'ویرایش کاربر'"
@@ -168,6 +318,9 @@ onMounted(load);
:disabled="editing === auth.user?.id"
:options="ROLE_OPTIONS"
/>
<div class="mt-1.5 text-[11px] text-gray-500">
{{ model.role === 'ADMIN' ? 'مدیر کل: دسترسی کامل سیستمی، حذف داده‌ها، تنظیمات و مدیریت کاربران' : 'ویرایشگر: ویرایش محتوا، محصولات، بخش‌ها و پیام‌ها (بدون حذف)' }}
</div>
</FormField>
<FormField v-if="!isNew" path="isActive">
<a-checkbox v-model:checked="model.isActive" :disabled="editing === auth.user?.id">حساب فعال است</a-checkbox>
+69
View File
@@ -39,3 +39,72 @@ textarea.ltr-input {
direction: ltr;
text-align: left;
}
/* Remove borders from all icon buttons and action buttons across the admin panel */
.ant-btn-icon-only,
.ant-btn.ant-btn-icon-only,
.ant-table .ant-btn:not(.ant-btn-primary),
.ant-space .ant-btn:not(.ant-btn-primary),
.ant-btn:has(> .vx-icon:only-child):not(.ant-btn-primary),
.ant-btn:has(> span > .vx-icon:only-child):not(.ant-btn-primary) {
border: none !important;
box-shadow: none !important;
}
.ant-btn-icon-only:not(.ant-btn-primary):not(:hover),
.ant-table .ant-btn:not(.ant-btn-primary):not(:hover),
.ant-space .ant-btn:not(.ant-btn-primary):not(:hover) {
background: transparent !important;
}
.ant-btn-icon-only:not(.ant-btn-primary):hover,
.ant-table .ant-btn:not(.ant-btn-primary):hover,
.ant-space .ant-btn:not(.ant-btn-primary):hover {
border: none !important;
background: #f1f5f9 !important;
}
.ant-btn-icon-only.ant-btn-dangerous,
.ant-table .ant-btn.ant-btn-dangerous,
.ant-space .ant-btn.ant-btn-dangerous {
border: none !important;
color: #ef4444 !important;
}
.ant-btn-icon-only.ant-btn-dangerous:hover,
.ant-table .ant-btn.ant-btn-dangerous:hover,
.ant-space .ant-btn.ant-btn-dangerous:hover {
border: none !important;
background: #fee2e2 !important;
color: #dc2626 !important;
}
/* Ensure proper spacing and optical vertical alignment for icons in buttons across the admin */
.ant-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
}
.ant-btn > .anticon,
.ant-btn > .vx-icon,
.ant-btn > .ant-btn-icon {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
line-height: 0 !important;
vertical-align: middle !important;
}
.ant-btn-icon-only {
gap: 0 !important;
}
.ant-btn-icon-only > .vx-icon,
.ant-btn-icon-only > .anticon,
.ant-btn-icon-only > .ant-btn-icon {
margin: 0 !important;
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"build": "prisma generate && nest build",
"dev": "nest start --watch --preserveWatchOutput",
"dev": "nest start --watch --preserveWatchOutput --entryFile main.js",
"start": "node dist/main.js",
"typecheck": "prisma generate && tsc --noEmit -p tsconfig.json",
"test": "vitest run --config ./vitest.config.e2e.ts",
+3 -1
View File
@@ -22,7 +22,7 @@ import {
reorderSchema,
} from "@imen/shared";
import { z } from "zod";
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
import { AdminAuth, type AuthUser, CurrentUser, Roles } from "../common/auth.js";
import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import { MediaModule } from "../media/media.controller.js";
import { CategoriesService } from "./categories.service.js";
@@ -68,6 +68,7 @@ export class CategoriesController {
return this.categories.update(id, body, user.id);
}
@Roles("ADMIN")
@Delete(":id")
@HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
@@ -124,6 +125,7 @@ export class ProductsController {
return this.products.update(id, body, user.id);
}
@Roles("ADMIN")
@Delete(":id")
@HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
+2 -1
View File
@@ -24,7 +24,7 @@ import {
} from "@imen/shared";
import type { Request } from "express";
import { z } from "zod";
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
import { AdminAuth, type AuthUser, CurrentUser, Roles } from "../common/auth.js";
import { AuditService } from "../common/common.module.js";
import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import type { ContactMessage } from "../generated/prisma/client.js";
@@ -123,6 +123,7 @@ export class MessagesController {
return this.contact.setStatus(id, body.status);
}
@Roles("ADMIN")
@Delete(":id")
@HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
+75 -15
View File
@@ -1,9 +1,11 @@
import { Controller, Get, Module } from "@nestjs/common";
import type { DashboardDto } from "@imen/shared";
import { type DashboardDto, SECTION_KEYS } from "@imen/shared";
import { AdminAuth } from "../common/auth.js";
import { toMessageDto } from "../contact/contact.module.js";
import { PrismaService } from "../prisma/prisma.service.js";
const DAY_NAMES = ["یکشنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه"];
@AdminAuth()
@Controller("admin/dashboard")
export class DashboardController {
@@ -11,20 +13,61 @@ export class DashboardController {
@Get()
async get(): Promise<DashboardDto> {
const [categories, products, draftProducts, newMessages, media, messages, activity] =
await Promise.all([
this.prisma.category.count(),
this.prisma.product.count(),
this.prisma.product.count({ where: { isPublished: false } }),
this.prisma.contactMessage.count({ where: { status: "NEW" } }),
this.prisma.media.count(),
this.prisma.contactMessage.findMany({ orderBy: { id: "desc" }, take: 5 }),
this.prisma.auditLog.findMany({
orderBy: { id: "desc" },
take: 10,
include: { user: { select: { name: true } } },
}),
]);
const [
categories,
products,
draftProducts,
newMessages,
readMessages,
archivedMessages,
media,
messages,
activity,
categoryRows,
sectionRows,
] = await Promise.all([
this.prisma.category.count(),
this.prisma.product.count(),
this.prisma.product.count({ where: { isPublished: false } }),
this.prisma.contactMessage.count({ where: { status: "NEW" } }),
this.prisma.contactMessage.count({ where: { status: "READ" } }),
this.prisma.contactMessage.count({ where: { status: "ARCHIVED" } }),
this.prisma.media.count(),
this.prisma.contactMessage.findMany({ orderBy: { id: "desc" }, take: 6 }),
this.prisma.auditLog.findMany({
orderBy: { id: "desc" },
take: 10,
include: { user: { select: { name: true } } },
}),
this.prisma.category.findMany({
select: { id: true, name: true, _count: { select: { products: true } } },
orderBy: { products: { _count: "desc" } },
take: 7,
}),
this.prisma.siteSection.findMany(),
]);
let hiddenCount = 0;
for (const s of sectionRows) {
if (s.data && typeof s.data === "object" && (s.data as Record<string, unknown>).enabled === false) {
hiddenCount++;
}
}
const now = new Date();
const trend = [];
for (let i = 6; i >= 0; i--) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i, 0, 0, 0);
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i, 23, 59, 59, 999);
const count = await this.prisma.contactMessage.count({
where: { createdAt: { gte: start, lte: end } },
});
trend.push({
date: start.toISOString().slice(0, 10),
dayName: DAY_NAMES[start.getDay()],
messages: count,
});
}
return {
categories,
@@ -41,6 +84,23 @@ export class DashboardController {
user: a.user?.name ?? null,
createdAt: a.createdAt.toISOString(),
})),
categoryStats: categoryRows.map((c) => ({
id: c.id,
name: c.name,
count: c._count.products,
})),
messageBreakdown: {
new: newMessages,
read: readMessages,
archived: archivedMessages,
total: newMessages + readMessages + archivedMessages,
},
sectionsSummary: {
total: SECTION_KEYS.length,
visible: SECTION_KEYS.length - hiddenCount,
hidden: hiddenCount,
},
trend,
};
}
}
+2 -1
View File
@@ -15,7 +15,7 @@ import {
import { FileInterceptor } from "@nestjs/platform-express";
import { mediaUpdateSchema } from "@imen/shared";
import { z } from "zod";
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
import { AdminAuth, type AuthUser, CurrentUser, Roles } from "../common/auth.js";
import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import { MediaService } from "./media.service.js";
@@ -54,6 +54,7 @@ export class MediaController {
return this.media.updateAlt(id, body.alt, user.id);
}
@Roles("ADMIN")
@Delete(":id")
@HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
+19 -15
View File
@@ -50,22 +50,26 @@ export class S3Storage {
} catch {
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
}
await this.client.send(
new PutBucketPolicyCommand({
Bucket: this.bucket,
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { AWS: ["*"] },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${this.bucket}/*`],
},
],
try {
await this.client.send(
new PutBucketPolicyCommand({
Bucket: this.bucket,
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { AWS: ["*"] },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${this.bucket}/*`],
},
],
}),
}),
}),
);
);
} catch {
// PutBucketPolicy is not supported by all S3 emulators (e.g. s3rver), ignore
}
}
}
+79
View File
@@ -8,6 +8,7 @@ import {
Module,
NotFoundException,
Param,
Patch,
Put,
ServiceUnavailableException,
} from "@nestjs/common";
@@ -75,6 +76,68 @@ export class SectionsService {
this.revalidate.trigger();
return { key, data: parsed.data, updatedAt: row.updatedAt };
}
async listAdmin() {
const rows = await this.prisma.siteSection.findMany();
const byKey = new Map(rows.map((r) => [r.key, r]));
return SECTION_KEYS.map((key) => {
const row = byKey.get(key);
let data: any = null;
if (row) {
const parsed = sectionSchemas[key].safeParse(row.data);
data = parsed.success ? parsed.data : row.data;
}
return {
key,
updatedAt: row?.updatedAt?.toISOString() ?? null,
enabled:
data && typeof data === "object" && "enabled" in data
? (data.enabled as boolean) !== false
: true,
};
});
}
async setVisibility(key: string, enabled: boolean, user: AuthUser) {
if (!isSectionKey(key)) throw new NotFoundException("بخش پیدا نشد");
if (key === "settings") {
throw new BadRequestException("تنظیمات سایت قابلیت تغییر وضعیت نمایش ندارد");
}
const row = await this.prisma.siteSection.findUnique({ where: { key } });
if (!row) throw new NotFoundException("بخش پیدا نشد");
const currentData =
row.data && typeof row.data === "object" ? { ...(row.data as object) } : {};
(currentData as Record<string, unknown>).enabled = enabled;
const parsed = sectionSchemas[key].safeParse(currentData);
if (!parsed.success) {
throw new BadRequestException({
statusCode: 400,
message: "اطلاعات بخش معتبر نیست",
issues: toIssues(parsed.error),
});
}
const updated = await this.prisma.siteSection.update({
where: { key },
data: { data: parsed.data, updatedById: user.id },
});
this.audit.log(
user.id,
"update",
"section",
key,
`تغییر وضعیت نمایش به ${enabled ? "نمایش" : "عدم نمایش"}`,
);
this.revalidate.trigger();
return {
key,
enabled: ((parsed.data as any).enabled as boolean) !== false,
updatedAt: updated.updatedAt.toISOString(),
};
}
}
@AdminAuth()
@@ -82,6 +145,11 @@ export class SectionsService {
export class SectionsController {
constructor(private readonly sections: SectionsService) {}
@Get()
list() {
return this.sections.listAdmin();
}
@Get(":key")
get(@Param("key") key: string) {
return this.sections.get(key);
@@ -91,6 +159,17 @@ export class SectionsController {
update(@Param("key") key: string, @Body() body: unknown, @CurrentUser() user: AuthUser) {
return this.sections.update(key, body, user);
}
@Patch(":key/visibility")
setVisibility(
@Param("key") key: string,
@Body() body: { enabled: boolean },
@CurrentUser() user: AuthUser,
) {
const enabled =
body && typeof body.enabled === "boolean" ? body.enabled : Boolean(body?.enabled);
return this.sections.setVisibility(key, enabled, user);
}
}
@Module({
+46
View File
@@ -0,0 +1,46 @@
import { normalizeIrMobile } from "@imen/shared";
import { hashPassword } from "../auth/auth.service.js";
import { createPrismaClient } from "../prisma/prisma.service.js";
const prisma = createPrismaClient();
async function main() {
const phone = normalizeIrMobile("09128081392");
const password = "09128081392";
const name = "مدیر سایت";
const role = "ADMIN";
const passwordHash = await hashPassword(password);
const existing = await prisma.adminUser.findUnique({ where: { phone } });
if (existing) {
const updated = await prisma.adminUser.update({
where: { id: existing.id },
data: {
passwordHash,
name,
role,
},
});
console.log(`User already existed, password updated for: ${updated.phone} (ID: ${updated.id})`);
} else {
const user = await prisma.adminUser.create({
data: {
phone,
passwordHash,
name,
role,
},
});
console.log(`User successfully created: ${user.phone} (ID: ${user.id}, Role: ${user.role})`);
}
}
try {
await main();
} catch (e) {
console.error("Error creating user:", e);
process.exitCode = 1;
} finally {
await prisma.$disconnect();
}
+11
View File
@@ -40,6 +40,7 @@ export const SECTIONS: SiteSections = {
},
hero: {
enabled: true,
badge: "تأمین‌کننده رسمی تجهیزات حفاظت فردی (PPE)",
title: "ایمنی یعنی",
titleHighlight: "بازگشت به خانه",
@@ -62,6 +63,7 @@ export const SECTIONS: SiteSections = {
},
tape: {
enabled: true,
items: [
"ISO 9001:2015",
"CE CERTIFIED",
@@ -75,6 +77,7 @@ export const SECTIONS: SiteSections = {
},
stats: {
enabled: true,
items: [
{ end: 18, suffix: "+", label: "سال تجربه در صنعت ایمنی", note: "SINCE 2007" },
{ end: 2400, suffix: "+", label: "کد کالای ایمنی", note: "PPE CATALOG" },
@@ -84,6 +87,7 @@ export const SECTIONS: SiteSections = {
},
productsIntro: {
enabled: true,
head: {
tag: "۰۱ — محصولات",
title: "خط اول دفاع،\nتجهیزات ماست",
@@ -111,6 +115,7 @@ export const SECTIONS: SiteSections = {
},
about: {
enabled: true,
head: {
tag: "۰۲ — درباره ما",
title: "ایمنی برای ما شعار نیست؛",
@@ -134,6 +139,7 @@ export const SECTIONS: SiteSections = {
},
services: {
enabled: true,
head: {
tag: "۰۳ — خدمات",
title: "فراتر از تأمین؛\nهمراهی کامل ایمنی",
@@ -185,6 +191,7 @@ export const SECTIONS: SiteSections = {
},
standards: {
enabled: true,
head: {
tag: "۰۴ — استانداردها",
title: "مهرتأیید جهانی\nروی هر محصول",
@@ -204,6 +211,7 @@ export const SECTIONS: SiteSections = {
},
industries: {
enabled: true,
head: {
tag: "۰۵ — صنایع",
title: "حضور در سخت‌ترین\nمیدان‌های کاری",
@@ -221,6 +229,7 @@ export const SECTIONS: SiteSections = {
},
testimonials: {
enabled: true,
head: { tag: "۰۶ — اعتماد", title: "آنچه مشتریان ما می‌گویند", highlight: "", desc: "" },
items: [
{
@@ -242,6 +251,7 @@ export const SECTIONS: SiteSections = {
},
contact: {
enabled: true,
head: {
tag: "۰۷ — تماس",
title: "بیایید ایمنی را\nبا هم جدی بگیریم",
@@ -258,6 +268,7 @@ export const SECTIONS: SiteSections = {
},
footer: {
enabled: true,
about:
"تأمین‌کننده تخصصی تجهیزات حفاظت فردی و راهکارهای ایمنی صنعتی؛ همراه سازمان‌ها از سال ۱۳۸۶ در مسیر صفر شدن حوادث کاری.",
copyright: "© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.",
+95 -3
View File
@@ -7,7 +7,7 @@
* SEED_OVERWRITE_SECTIONS=1 ... (reset website sections to the initial content)
*/
import { env } from "../config/env.js";
import { readFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeIrMobile, SECTION_KEYS, sectionSchemas } from "@imen/shared";
@@ -35,7 +35,15 @@ async function asset(rel: string, alt = "") {
let media = await prisma.media.findUnique({ where: { key } });
if (!media) {
const image = await toWebp(await readFile(path.join(ASSETS_DIR, rel)));
await storage.put(key, image.data, "image/webp");
try {
await storage.put(key, image.data, "image/webp");
} catch {
try {
const targetPath = path.resolve(ASSETS_DIR, "../../.s3data/imen-media", key);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, image.data);
} catch {}
}
media = await prisma.media.create({
data: {
key,
@@ -170,14 +178,98 @@ async function seedAdmin() {
console.log(` admin user ${phone}`);
}
async function seedMessages() {
const count = await prisma.contactMessage.count();
if (count > 0) return;
const samples = [
{
name: "مهندس رضا کاظمی",
org: "مجتمع پتروشیمی خلیج فارس",
phone: "09121112233",
message: "درخواست پیش‌فاکتور ۵۰۰ عدد هارنس ایمنی دو قلاب و کلاه ایمنی استاندارد EN 397 برای فاز ۲ پتروشیمی.",
status: "NEW" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 35),
},
{
name: "سارا محمدی",
org: "هلدینگ ساختمانی آرکان",
phone: "09122223344",
message: "استعلام قیمت و هماهنگی جلسه بازدید جهت عقد قرارداد سالانه تأمین تجهیزات حفاظت فردی ۱۲ پروژه فعال.",
status: "NEW" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 120),
},
{
name: "امیرحسین راد",
org: "صنایع معدنی کاوه",
phone: "09123334455",
message: "درخواست تأمین فوری ۲۰۰ جفت دستکش ایمنی ضدبرش و کفش ایمنی ضداسید و پنجه کامپوزیت برای سایت یزد.",
status: "READ" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 8),
},
{
name: "مهندس علیرضا بهرامی",
org: "شرکت نفت و گاز پارس",
phone: "09124445566",
message: "تقاضای ارسال کاتالوگ و نمونه عینک ایمنی استاندارد EN 166 و محافظ گوش ایرماف جهت آزمایش واحد HSE.",
status: "NEW" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24),
},
{
name: "مریم رضایی",
org: "داروسازی رازک",
phone: "09125556677",
message: "درخواست مشاوره تخصصی در رابطه با ماسک‌های فیلتردار ذرات شیمیایی و لباس‌های یکبار مصرف آزمایشگاهی.",
status: "READ" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 36),
},
{
name: "مهندس مهدی یوسفی",
org: "فولاد مبارکه اصفهان",
phone: "09126667788",
message: "درخواست برگزاری جلسه فنی برای تمدید قرارداد تأمین لباس کار نسوز، شیلد محافظ صورت و پابند نسوز.",
status: "ARCHIVED" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 72),
},
{
name: "پیمان نادری",
org: "خطوط لوله و مخابرات نفت",
phone: "09127778899",
message: "استعلام موجودی تجهیزات نجات در فضاهای بسته، سه‌پایه نجات (Tripod) و وینچ همراه با گواهی کالیبراسیون.",
status: "NEW" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 96),
},
{
name: "دکتر مجید اسدی",
org: "سیمان تهران",
phone: "09128889900",
message: "درخواست برگزاری دوره آموزشی کار در ارتفاع برای پرسنل پیمانکار و بازرسی دوره‌ای خطوط حیات (Lifeline).",
status: "READ" as const,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 120),
},
];
for (const msg of samples) {
await prisma.contactMessage.create({ data: msg });
}
console.log(` seeded ${samples.length} sample contact messages`);
}
async function main() {
if (env.S3_ENSURE_BUCKET) await storage.ensureBucket();
if (env.S3_ENSURE_BUCKET) {
try {
await storage.ensureBucket();
} catch {
console.log(" ensureBucket skipped");
}
}
console.log("seeding sections…");
await seedSections();
console.log("seeding catalog…");
await seedCatalog();
console.log("seeding admin…");
await seedAdmin();
console.log("seeding messages…");
await seedMessages();
const [categories, products, media] = await Promise.all([
prisma.category.count(),
prisma.product.count(),
+29
View File
@@ -211,3 +211,32 @@ body {
#0b1224 10px 20px
);
}
/* ---- hide scrollbar utility ---- */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ---- button icon alignment & gap ---- */
.ant-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
}
.ant-btn > .anticon,
.ant-btn > .vx-icon,
.ant-btn > .ant-btn-icon {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
line-height: 0 !important;
vertical-align: middle !important;
}
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { ArrowUp } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const show = useScrolledPast(700);
</script>
@@ -16,7 +16,7 @@ const show = useScrolledPast(700);
:exit="{ opacity: 0, y: 24 }"
class="fixed bottom-6 left-6 z-[70] flex size-12 items-center justify-center rounded-full border border-line bg-panel/90 text-ink shadow-[0_10px_40px_rgba(0,0,0,0.5)] backdrop-blur transition-colors hover:border-accent hover:text-accent"
>
<ArrowUp class="size-5" />
<VuesaxIcon name="arrow-up" class="size-5" />
</motion.a>
</AnimatePresence>
</template>
+29 -32
View File
@@ -1,45 +1,42 @@
<script setup lang="ts">
import type { SocialType } from "@imen/shared";
import {
AtSign,
Globe,
Instagram,
Linkedin,
MessageCircle,
Phone,
Send,
Youtube,
} from "lucide-vue-next";
import type { Component } from "vue";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import Logo from "~/components/ui/Logo.vue";
const QUICK_LINKS = [
{ href: "/#home", label: "خانه" },
{ href: "/#products", label: "محصولات" },
{ href: "/#about", label: "درباره ما" },
{ href: "/#services", label: "خدمات" },
{ href: "/#standards", label: "استانداردها" },
{ href: "/#industries", label: "صنایع" },
];
const ALL_QUICK_LINKS = [
{ href: "/#home", label: "خانه", section: "hero" },
{ href: "/#products", label: "محصولات", section: "productsIntro" },
{ href: "/#about", label: "درباره ما", section: "about" },
{ href: "/#services", label: "خدمات", section: "services" },
{ href: "/#standards", label: "استانداردها", section: "standards" },
{ href: "/#industries", label: "صنایع", section: "industries" },
] as const;
const SOCIAL_META: Record<SocialType, { icon: Component; label: string }> = {
website: { icon: Globe, label: "وب‌سایت" },
telegram: { icon: Send, label: "تلگرام" },
instagram: { icon: Instagram, label: "اینستاگرام" },
linkedin: { icon: Linkedin, label: "لینکدین" },
whatsapp: { icon: MessageCircle, label: "واتساپ" },
bale: { icon: MessageCircle, label: "بله" },
eitaa: { icon: MessageCircle, label: "ایتا" },
aparat: { icon: Youtube, label: "آپارات" },
youtube: { icon: Youtube, label: "یوتیوب" },
email: { icon: AtSign, label: "ایمیل" },
phone: { icon: Phone, label: "تماس" },
const SOCIAL_META: Record<SocialType, { icon: string; label: string }> = {
website: { icon: "global", label: "وب‌سایت" },
telegram: { icon: "send-2", label: "تلگرام" },
instagram: { icon: "instagram", label: "اینستاگرام" },
linkedin: { icon: "link", label: "لینکدین" },
whatsapp: { icon: "messages-2", label: "واتساپ" },
bale: { icon: "messages-2", label: "بله" },
eitaa: { icon: "messages-2", label: "ایتا" },
aparat: { icon: "video-play", label: "آپارات" },
youtube: { icon: "video-play", label: "یوتیوب" },
email: { icon: "sms", label: "ایمیل" },
phone: { icon: "call", label: "تماس" },
};
const site = useSiteStore();
const footer = computed(() => site.sections.footer);
const settings = computed(() => site.settings);
const QUICK_LINKS = computed(() =>
ALL_QUICK_LINKS.filter(
(l) =>
(site.sections as Record<string, { enabled?: boolean }>)?.[l.section]?.enabled !== false,
),
);
const PRODUCT_LINKS = computed(() =>
site.categories.map((c) => ({ label: c.name, href: `/categories/${c.slug}` })),
);
@@ -75,7 +72,7 @@ const SOCIALS = computed(() =>
:rel="s.external ? 'noopener' : undefined"
class="flex size-10 items-center justify-center rounded-lg border border-line text-muted transition-all duration-300 hover:border-accent hover:bg-accent hover:text-bg"
>
<component :is="s.icon" class="size-4" />
<VuesaxIcon :name="s.icon" class="size-4" />
</a>
</div>
</div>
+25 -15
View File
@@ -1,20 +1,30 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { Menu, Phone, X } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import Logo from "~/components/ui/Logo.vue";
const LINKS = [
{ href: "/#home", label: "خانه" },
{ href: "/#products", label: "محصولات" },
{ href: "/#about", label: "درباره ما" },
{ href: "/#services", label: "خدمات" },
{ href: "/#standards", label: "استانداردها" },
{ href: "/#industries", label: "صنایع" },
{ href: "/#contact", label: "تماس" },
];
const ALL_LINKS = [
{ href: "/#home", label: "خانه", section: "hero" },
{ href: "/#products", label: "محصولات", section: "productsIntro" },
{ href: "/#about", label: "درباره ما", section: "about" },
{ href: "/#services", label: "خدمات", section: "services" },
{ href: "/#standards", label: "استانداردها", section: "standards" },
{ href: "/#industries", label: "صنایع", section: "industries" },
{ href: "/#contact", label: "تماس", section: "contact" },
] as const;
const ui = useUiStore();
const settings = computed(() => useSiteStore().settings);
const site = useSiteStore();
const settings = computed(() => site.settings);
const sections = computed(() => site.sections);
const LINKS = computed(() =>
ALL_LINKS.filter(
(l) =>
(sections.value as Record<string, { enabled?: boolean }>)?.[l.section]?.enabled !== false,
),
);
const scrolled = useScrolledPast(40);
const { time, shift } = useTehranClock();
@@ -69,7 +79,7 @@ onBeforeUnmount(() => {
href="/#contact"
class="hidden items-center gap-2 rounded-full bg-accent px-5 py-2.5 text-sm font-bold text-bg transition-all duration-300 hover:bg-accent-soft hover:shadow-[0_0_30px_rgba(255,90,0,0.4)] md:flex"
>
<Phone class="size-4" />
<VuesaxIcon name="call" class="size-4" />
درخواست مشاوره
</a>
@@ -78,7 +88,7 @@ onBeforeUnmount(() => {
class="flex size-11 items-center justify-center rounded-lg border border-line text-ink transition-colors hover:border-accent hover:text-accent lg:hidden"
@click="ui.openMenu"
>
<Menu class="size-5" />
<VuesaxIcon name="menu" class="size-5" />
</button>
</div>
</div>
@@ -100,7 +110,7 @@ onBeforeUnmount(() => {
class="flex size-11 items-center justify-center rounded-lg border border-line transition-colors hover:border-accent hover:text-accent"
@click="ui.closeMenu"
>
<X class="size-5" />
<VuesaxIcon name="close-circle" class="size-5" />
</button>
</div>
@@ -126,7 +136,7 @@ onBeforeUnmount(() => {
<div class="flex items-center justify-between border-t border-line p-8 text-sm">
<div class="flex items-center gap-3 text-muted">
<Phone class="size-4 text-accent" />
<VuesaxIcon name="call" class="size-4 text-accent" />
<a :href="`tel:${settings.phone}`" class="font-mono" dir="ltr">{{ settings.phoneDisplay }}</a>
</div>
<div class="text-xs text-muted">تهران — شیفت {{ shift }}، {{ time }}</div>
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { motion } from "motion-v";
import { ShieldCheck } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const STEPS = [
"بررسی سیستم‌های ایمنی",
@@ -51,7 +51,7 @@ const step = computed(
<div class="flex items-center gap-4">
<span class="flex size-12 items-center justify-center rounded-xl bg-accent text-bg">
<ShieldCheck class="size-7" />
<VuesaxIcon name="shield-tick" class="size-7" />
</span>
<span class="font-mono text-xs tracking-[0.35em] text-muted">
IMEN PISHGAMAN CO.
+10 -19
View File
@@ -1,16 +1,7 @@
<script setup lang="ts">
import { Button as AButton, Form as AForm, FormItem as AFormItem, Input as AInput, Textarea as ATextarea } from "ant-design-vue";
import type { Rule } from "ant-design-vue/es/form";
import {
CheckCircle2,
Clock,
Loader2,
Mail,
MapPin,
Phone,
Send,
Siren,
} from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import Lines from "~/components/ui/Lines.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
@@ -21,10 +12,10 @@ const content = computed(() => site.sections.contact);
const INFO = computed(() => {
const s = site.settings;
return [
{ icon: MapPin, label: "نشانی", value: `${s.address.locality}، ${s.address.street}` },
{ icon: Phone, label: "تلفن تماس", value: s.phoneDisplay, ltr: true, href: `tel:${s.phone}` },
{ icon: Mail, label: "ایمیل", value: s.email, ltr: true, href: `mailto:${s.email}` },
{ icon: Clock, label: "ساعات کاری", value: s.workingHours },
{ icon: "location", label: "نشانی", value: `${s.address.locality}، ${s.address.street}` },
{ icon: "call", label: "تلفن تماس", value: s.phoneDisplay, ltr: true, href: `tel:${s.phone}` },
{ icon: "sms", label: "ایمیل", value: s.email, ltr: true, href: `mailto:${s.email}` },
{ icon: "clock", label: "ساعات کاری", value: s.workingHours },
];
});
@@ -61,7 +52,7 @@ const contact = useContactStore();
<span
class="flex size-11 shrink-0 items-center justify-center rounded-lg border border-line text-accent transition-colors duration-300 group-hover:border-accent/50"
>
<component :is="item.icon" class="size-5" />
<VuesaxIcon :name="item.icon" class="size-5" />
</span>
<div class="min-w-0">
<div class="text-xs text-muted">{{ item.label }}</div>
@@ -80,7 +71,7 @@ const contact = useContactStore();
<Reveal :delay="0.45">
<div class="mt-8 flex items-start gap-4 rounded-xl border border-hazard/30 bg-hazard/5 p-5">
<Siren class="size-6 shrink-0 text-hazard" />
<VuesaxIcon name="lamp-on" class="size-6 shrink-0 text-hazard" />
<div>
<div class="font-bold text-hazard">{{ content.emergencyTitle }}</div>
<p class="mt-1.5 text-sm leading-7 text-muted">
@@ -105,7 +96,7 @@ const contact = useContactStore();
v-if="contact.status === 'success'"
class="mt-10 flex flex-col items-center rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-10 text-center"
>
<CheckCircle2 class="size-14 text-emerald-400" />
<VuesaxIcon name="tick-circle" class="size-14 text-emerald-400" />
<div class="mt-5 text-xl font-black">{{ content.successTitle }}</div>
<p class="mt-2.5 max-w-sm text-sm leading-7 text-muted">
{{ content.successText }}
@@ -176,11 +167,11 @@ const contact = useContactStore();
:disabled="contact.status === 'loading'"
>
<template v-if="contact.status === 'loading'">
<Loader2 class="size-5 animate-spin" />
<VuesaxIcon name="refresh-circle" class="size-5 animate-spin" />
در حال ارسال...
</template>
<template v-else>
<Send class="size-5" />
<VuesaxIcon name="send-2" class="size-5" />
ارسال درخواست مشاوره
</template>
</AButton>
+39 -83
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ArrowLeft, ChevronDown, HardHat } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
const hero = computed(() => useSiteStore().sections.hero);
@@ -16,68 +16,81 @@ const hero = computed(() => useSiteStore().sections.hero);
height="1008"
fetchpriority="high"
decoding="async"
class="absolute inset-0 size-full object-cover"
class="absolute inset-0 size-full object-cover object-[center_15%] sm:object-right-top"
/>
<div class="absolute inset-0 bg-gradient-to-l from-bg via-bg/80 to-bg/25" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-transparent to-bg/60" />
<div class="absolute inset-0 bg-grid opacity-50" />
<!-- Mobile Gradient: 0 at the top so image is fully clear, reaches 100% at the texts -->
<div
class="sm:hidden absolute inset-0 pointer-events-none"
style="background: linear-gradient(to bottom, transparent 0%, transparent 26%, rgba(11, 18, 36, 0.45) 42%, rgba(11, 18, 36, 0.95) 54%, #0b1224 62%, #0b1224 100%);"
/>
<!-- Desktop Gradient: Right side dark for text, left side fading out as requested -->
<div class="hidden sm:block absolute inset-0 bg-gradient-to-l from-bg via-bg/65 via-25% to-transparent" />
<div class="hidden sm:block absolute inset-0 bg-gradient-to-t from-bg via-transparent to-bg/50" />
<div class="hidden sm:block absolute inset-0 bg-grid opacity-30" />
</div>
<!-- ---- side tape ---- -->
<div class="tape-v absolute bottom-0 end-0 z-10 h-44 w-1.5 opacity-70" />
<div class="tape-v absolute bottom-0 end-0 z-10 h-32 sm:h-44 w-1 sm:w-1.5 opacity-70" />
<!-- ---- content ---- -->
<div
class="relative z-10 mx-auto flex w-full max-w-[1480px] flex-1 items-center px-5 pb-24 pt-36 md:px-8"
class="relative z-10 mx-auto flex w-full max-w-[1480px] flex-1 items-end sm:items-center px-4 pb-6 pt-32 sm:pt-32 md:pt-36 sm:px-6 md:px-8 md:pb-24"
>
<div class="grid w-full items-center gap-12 lg:grid-cols-12">
<div class="lg:col-span-7">
<div class="w-full max-w-3xl">
<div>
<!-- Badge -->
<div
class="animate-fade-up-1 inline-flex items-center gap-2.5 rounded-full border border-accent/30 bg-accent/10 px-4 py-2 backdrop-blur"
class="animate-fade-up-1 inline-flex items-center gap-1.5 sm:gap-2.5 rounded-full border border-accent/30 bg-accent/10 px-3 py-1 sm:px-4 sm:py-2 backdrop-blur"
>
<span class="size-1.5 animate-pulse rounded-full bg-accent" />
<span class="text-xs font-medium text-accent">
<span class="text-[11px] sm:text-xs font-medium text-accent">
{{ hero.badge }}
</span>
</div>
<!-- Title & Highlight -->
<h1
class="animate-fade-up-2 mt-7 text-5xl font-black leading-[1.15] md:text-7xl md:leading-[1.1]"
class="animate-fade-up-2 mt-3 sm:mt-7 text-2xl sm:text-4xl md:text-6xl font-black leading-tight sm:leading-[1.15] md:leading-[1.1]"
>
{{ hero.title }}
<br />
<span
class="mt-3 inline-block -rotate-1 rounded-lg bg-hazard px-4 py-1.5 text-bg shadow-[0_10px_50px_rgba(255,184,0,0.25)]"
class="mt-2 inline-block whitespace-nowrap rounded-md bg-hazard px-2.5 py-1 text-lg font-black text-bg shadow-sm sm:mt-3 sm:rounded-lg sm:px-4 sm:py-1.5 sm:text-3xl sm:-rotate-1 sm:shadow-[0_10px_50px_rgba(255,184,0,0.25)] md:text-5xl"
>
{{ hero.titleHighlight }}
</span>
</h1>
<!-- Intro text -->
<p
class="animate-fade-up-3 mt-7 max-w-xl text-base leading-9 text-ink/70 md:text-lg md:leading-10"
class="animate-fade-up-3 mt-3 sm:mt-6 max-w-xl text-[12px] sm:text-base leading-relaxed sm:leading-9 text-ink/75 md:text-lg md:leading-10"
>
{{ hero.intro }}
</p>
<div class="animate-fade-up-4 mt-9 flex flex-wrap items-center gap-4">
<!-- CTA buttons -->
<div class="animate-fade-up-4 mt-5 sm:mt-9 flex flex-wrap items-center gap-2.5 sm:gap-4">
<a
:href="hero.primaryCta.href"
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)]"
class="group inline-flex items-center gap-1.5 sm:gap-2.5 rounded-full bg-accent px-4 py-2.5 sm:px-7 sm:py-4 text-xs sm:text-sm md:text-base font-bold text-bg transition-all duration-300 hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
>
{{ hero.primaryCta.label }}
<ArrowLeft
class="size-5 transition-transform duration-300 group-hover:-translate-x-1.5"
<VuesaxIcon
name="arrow-left"
class="size-3.5 sm:size-5 transition-transform duration-300 group-hover:-translate-x-1.5"
/>
</a>
<a
:href="hero.secondaryCta.href"
class="inline-flex items-center gap-2.5 rounded-full border border-ink/25 px-7 py-4 font-bold text-ink backdrop-blur transition-colors duration-300 hover:border-accent hover:text-accent"
class="inline-flex items-center gap-2 rounded-full border border-ink/25 px-4 py-2.5 sm:px-7 sm:py-4 text-xs sm:text-sm md:text-base font-bold text-ink backdrop-blur transition-colors duration-300 hover:border-accent hover:text-accent"
>
{{ hero.secondaryCta.label }}
</a>
</div>
<div class="animate-fade-up-5 mt-10 flex flex-wrap gap-3">
<!-- Feature Chips (desktop/tablet only to keep mobile clean) -->
<div class="animate-fade-up-5 mt-6 sm:mt-10 hidden sm:flex flex-wrap gap-3">
<span
v-for="c in hero.chips"
:key="c.label"
@@ -88,84 +101,27 @@ const hero = computed(() => useSiteStore().sections.hero);
</span>
</div>
</div>
<!-- ---- rotating badge + status card ---- -->
<div class="hidden flex-col items-center gap-10 lg:col-span-5 lg:flex">
<div class="animate-fade-up-6 relative">
<div class="relative size-44">
<div class="animate-spin-slow absolute inset-0">
<svg viewBox="0 0 120 120" class="size-full">
<defs>
<path
id="badge-circle"
d="M60,60 m-47,0 a47,47 0 1,1 94,0 a47,47 0 1,1 -94,0"
/>
</defs>
<text font-size="9" letter-spacing="2.6" class="fill-ink/60 font-mono">
<textPath href="#badge-circle">
IMEN PISHGAMAN SAFETY • ISO 9001 • CE CERTIFIED • EST 2007 •
</textPath>
</text>
</svg>
</div>
<div class="absolute inset-0 flex items-center justify-center">
<span
class="flex size-20 items-center justify-center rounded-full border border-accent/30 bg-accent/10"
>
<HardHat class="size-9 text-accent" />
</span>
</div>
</div>
</div>
<div
class="animate-fade-up-7 w-full max-w-sm border border-line bg-bg/70 p-5 backdrop-blur-md"
>
<div class="flex items-center justify-between font-mono text-[11px]">
<span class="text-muted">SITE STATUS</span>
<span class="flex items-center gap-2 text-emerald-400">
<span class="size-1.5 animate-pulse rounded-full bg-emerald-400" />
SECURE
</span>
</div>
<div class="mt-3.5 flex items-center justify-between font-mono text-[11px]">
<span class="text-muted">PPE COMPLIANCE</span>
<span class="font-semibold text-accent">۱۰۰٪</span>
</div>
<div
v-if="hero.lastInspection"
class="mt-3.5 flex items-center justify-between font-mono text-[11px]"
>
<span class="text-muted">آخرین بازرسی</span>
<span>{{ hero.lastInspection }}</span>
</div>
<div class="my-4 h-px bg-line" />
<div class="font-mono text-[11px] leading-6 text-muted">
سیستم پایش حرفه‌ای ایمن پیشگامان<span class="animate-blink text-accent">▍</span>
</div>
</div>
</div>
</div>
</div>
<!-- ---- scroll hint ---- -->
<div class="animate-fade-up-9 absolute bottom-28 left-1/2 z-10 hidden md:block">
<div class="animate-fade-up-9 absolute bottom-24 sm:bottom-28 left-1/2 z-10 hidden md:block">
<div class="-translate-x-1/2 flex flex-col items-center gap-2">
<span class="font-mono text-[10px] tracking-[0.35em] text-muted">SCROLL</span>
<ChevronDown class="size-4 animate-bounce text-accent" />
<VuesaxIcon name="arrow-down-1" class="size-4 animate-bounce text-accent" />
</div>
</div>
<!-- ---- bottom bar ---- -->
<div class="animate-fade-up-8 relative z-10 border-t border-ink/10 backdrop-blur-sm">
<div class="mx-auto grid max-w-[1480px] grid-cols-1 gap-px bg-ink/10 sm:grid-cols-3">
<div class="mx-auto grid max-w-[1480px] grid-cols-3 gap-px bg-ink/10">
<div
v-for="s in hero.miniStats"
:key="s.label"
class="flex items-baseline gap-4 bg-bg/60 px-6 py-5 backdrop-blur-md md:px-8"
class="flex flex-col sm:flex-row sm:items-baseline sm:gap-4 bg-bg/60 px-2.5 py-3 text-center sm:text-right backdrop-blur-md sm:px-6 sm:py-5 md:px-8"
>
<span class="text-2xl font-black text-ink md:text-3xl">{{ s.value }}</span>
<span class="text-xs leading-5 text-muted md:text-sm">{{ s.label }}</span>
<span class="text-base font-black text-ink sm:text-2xl md:text-3xl">{{ s.value }}</span>
<span class="mt-0.5 sm:mt-0 text-[10px] sm:text-xs leading-tight text-muted md:text-sm">{{ s.label }}</span>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence, motion, useMotionValue, useSpring } from "motion-v";
import { ArrowLeft } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const industries = computed(() => useSiteStore().sections.industries);
@@ -59,7 +59,7 @@ const onMove = (e: MouseEvent) => {
<span
class="flex size-10 items-center justify-center rounded-full border border-line text-muted transition-all duration-300 group-hover:-translate-x-1.5 group-hover:border-accent group-hover:text-accent md:size-12"
>
<ArrowLeft class="size-4 md:size-5" />
<VuesaxIcon name="arrow-left" class="size-4 md:size-5" />
</span>
</div>
</motion.div>
+149 -37
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ArrowLeft } from "lucide-vue-next";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
@@ -8,11 +7,65 @@ const site = useSiteStore();
const intro = computed(() => site.sections.productsIntro);
const categories = computed(() => site.categories);
// Desktop active accordion index
const active = ref(0);
// Mobile interactive state
const activeIndex = ref(0);
const scrollContainer = ref<HTMLElement | null>(null);
function scrollToCategory(index: number) {
activeIndex.value = index;
if (!scrollContainer.value) return;
const cards = scrollContainer.value.children;
if (cards[index]) {
(cards[index] as HTMLElement).scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
}
}
function handleScroll() {
if (!scrollContainer.value) return;
const el = scrollContainer.value;
const containerRect = el.getBoundingClientRect();
const centerX = containerRect.left + containerRect.width / 2;
let closestIndex = 0;
let minDistance = Infinity;
Array.from(el.children).forEach((child, i) => {
const rect = (child as HTMLElement).getBoundingClientRect();
const childCenterX = rect.left + rect.width / 2;
const dist = Math.abs(centerX - childCenterX);
if (dist < minDistance) {
minDistance = dist;
closestIndex = i;
}
});
activeIndex.value = closestIndex;
}
function nextCategory() {
const count = categories.value.length;
if (count === 0) return;
const next = (activeIndex.value + 1) % count;
scrollToCategory(next);
}
function prevCategory() {
const count = categories.value.length;
if (count === 0) return;
const prev = (activeIndex.value - 1 + count) % count;
scrollToCategory(prev);
}
</script>
<template>
<section id="products" aria-labelledby="products-title" class="relative py-24 md:py-32">
<section id="products" aria-labelledby="products-title" class="relative py-20 sm:py-24 md:py-32">
<div class="mx-auto max-w-[1480px] px-5 md:px-8">
<div class="flex flex-wrap items-end justify-between gap-6">
<SectionHead heading-id="products-title" v-bind="intro.head" />
@@ -28,38 +81,96 @@ const active = ref(0);
</div>
</div>
<!-- ---- mobile: card grid ---- -->
<div class="mx-auto mt-14 max-w-[1480px] px-5 md:hidden">
<div class="grid grid-cols-2 gap-3">
<Reveal v-for="(cat, i) in categories" :key="cat.code" :delay="i * 0.05">
<NuxtLink
:to="`/categories/${cat.slug}`"
class="group relative block aspect-[3/4] overflow-hidden rounded-xl border border-line transition-colors duration-300 active:border-accent/60"
<!-- ---- mobile: compact interactive deck ---- -->
<div class="mx-auto mt-4 max-w-[1480px] px-4 md:hidden">
<!-- Snapping Cards Deck (compact size so more of side cards are visible) -->
<div
ref="scrollContainer"
class="flex items-center gap-4 overflow-x-auto snap-x snap-mandatory no-scrollbar -mx-4 px-8 py-8 scroll-smooth"
@scroll.passive="handleScroll"
>
<div
v-for="(cat, i) in categories"
:key="cat.code"
:class="`w-[54vw] max-w-[215px] aspect-[4/5] snap-center shrink-0 relative overflow-hidden rounded-xl border transition-all duration-500 ease-out ${
activeIndex === i
? 'border-accent/90 shadow-[0_0_20px_rgba(255,90,0,0.36)] ring-1 ring-accent/30 scale-100'
: 'border-line/70 opacity-55 scale-[0.92]'
}`"
>
<!-- Background Image -->
<img
v-if="cat.coverImage"
:src="cat.coverImage"
:alt="cat.name"
loading="lazy"
decoding="async"
:class="`absolute inset-0 size-full object-cover transition-transform duration-700 ${
activeIndex === i ? 'scale-105' : 'scale-100'
}`"
/>
<div v-else class="absolute inset-0 bg-panel" />
<!-- Clean Dark Gradient for Text Legibility -->
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 via-50% to-bg/15" />
<!-- Industrial Watermark Number in background -->
<div
class="absolute -top-2 end-1.5 font-mono text-[56px] font-black leading-none text-ink/5 select-none pointer-events-none"
>
<img
v-if="cat.coverImage"
:src="cat.coverImage"
:alt="cat.name"
loading="lazy"
decoding="async"
class="absolute inset-0 size-full object-cover transition-transform duration-500 group-active:scale-105"
/>
<div v-else class="absolute inset-0 bg-panel" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 to-transparent" />
{{ String(i + 1).padStart(2, "0") }}
</div>
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-3">
<span class="font-mono text-[9px] tracking-widest text-ink/50">{{ cat.code }}</span>
<span class="font-mono text-[10px] text-accent">
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
</div>
<!-- Top Row Badges -->
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-2.5 z-10">
<span class="inline-flex items-center gap-1 rounded-full border border-ink/20 bg-bg/80 px-1.5 py-0.5 font-mono text-[8.5px] font-bold text-accent backdrop-blur-md">
<span class="size-1 rounded-full bg-accent animate-pulse" />
{{ cat.code }}
</span>
<span class="rounded-full bg-bg/75 border border-ink/10 px-1.5 py-0.5 font-mono text-[8.5px] font-bold text-ink/80 backdrop-blur-md">
{{ faNum(String(i + 1).padStart(2, "0")) }} / {{ faNum(String(categories.length).padStart(2, "0")) }}
</span>
</div>
<div class="absolute inset-x-0 bottom-0 p-3">
<h3 class="text-sm font-black leading-6">{{ cat.name }}</h3>
<p class="mt-0.5 font-mono text-[8px] tracking-[0.15em] text-muted">{{ cat.nameEn }}</p>
</div>
</NuxtLink>
</Reveal>
<!-- Bottom Content Area -->
<div class="absolute inset-x-0 bottom-0 p-3 z-10">
<span class="font-mono text-[8.5px] tracking-[0.15em] text-hazard uppercase">
{{ cat.nameEn }}
</span>
<h3 class="mt-0.5 text-xs sm:text-sm font-black text-ink leading-tight line-clamp-1">
{{ cat.name }}
</h3>
<!-- Action Button -->
<NuxtLink
:to="`/categories/${cat.slug}`"
class="mt-2 inline-flex w-full items-center justify-between rounded-md bg-accent px-2.5 py-1.5 text-[10.5px] font-bold text-bg transition-all duration-300 hover:bg-accent-soft active:scale-[0.98] shadow-sm shadow-accent/25"
>
<span>مشاهده محصولات</span>
<ArrowLeft class="size-3" />
</NuxtLink>
</div>
</div>
</div>
<!-- Navigation Arrows Only (RTL correct: Left arrow scrolls left to next card, Right arrow scrolls right to previous card) -->
<div class="mt-1 flex items-center justify-center gap-3">
<button
type="button"
aria-label="دسته قبلی (سمت راست)"
class="flex size-10 items-center justify-center rounded-full border border-line bg-panel text-ink transition-colors hover:border-accent hover:text-accent active:scale-95 shadow-sm"
@click="prevCategory"
>
<ArrowRight class="size-4" />
</button>
<button
type="button"
aria-label="دسته بعدی (سمت چپ)"
class="flex size-10 items-center justify-center rounded-full border border-line bg-panel text-ink transition-colors hover:border-accent hover:text-accent active:scale-95 shadow-sm"
@click="nextCategory"
>
<ArrowLeft class="size-4" />
</button>
</div>
</div>
@@ -96,7 +207,7 @@ const active = ref(0);
:class="`absolute inset-0 transition-opacity duration-700 ${
active === i
? 'bg-gradient-to-t from-bg via-bg/35 to-bg/5'
: 'bg-gradient-to-t from-bg/95 via-bg/30 to-transparent'
: 'bg-gradient-to-t from-bg/95 via-bg/0 to-transparent'
}`"
/>
@@ -125,19 +236,20 @@ const active = ref(0);
</div>
</div>
<!-- ---- trust section ---- -->
<div
class="mx-auto mt-10 grid max-w-[1480px] gap-px overflow-hidden rounded-xl border border-line bg-line md:grid-cols-3"
>
<Reveal v-for="(t, i) in intro.trust" :key="t.title" :delay="i * 0.08">
<div class="flex h-full items-center gap-4 bg-panel/60 p-6">
<div class="flex h-full items-center gap-4 bg-panel/60 p-4 sm:p-6">
<span
class="flex size-12 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent"
class="flex size-11 sm:size-12 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent"
>
<DynamicIcon :name="t.icon" class="size-6" />
<DynamicIcon :name="t.icon" class="size-5 sm:size-6" />
</span>
<div>
<div class="font-bold">{{ t.title }}</div>
<div class="mt-1 text-xs leading-6 text-muted">{{ t.desc }}</div>
<div class="font-bold text-sm sm:text-base">{{ t.title }}</div>
<div class="mt-1 text-xs leading-5 sm:leading-6 text-muted">{{ t.desc }}</div>
</div>
</div>
</Reveal>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { Plus } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
@@ -50,7 +50,7 @@ const open = ref(0);
: 'border-line text-muted group-hover:border-accent/50 group-hover:text-accent'
}`"
>
<Plus class="size-5" />
<VuesaxIcon name="add" class="size-5" />
</span>
</button>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { FileSearch, ShieldCheck } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
@@ -26,7 +26,7 @@ const standards = computed(() => useSiteStore().sections.standards);
<SectionHead heading-id="standards-title" v-bind="standards.head" />
<Reveal v-if="standards.note" :delay="0.25">
<div class="mt-8 flex items-start gap-4 rounded-xl border border-accent/25 bg-accent/5 p-5">
<FileSearch class="size-6 shrink-0 text-accent" />
<VuesaxIcon name="document-filter" class="size-6 shrink-0 text-accent" />
<p class="text-sm leading-8 text-ink/80">
{{ standards.note }}
</p>
@@ -37,7 +37,7 @@ const standards = computed(() => useSiteStore().sections.standards);
href="#contact"
class="group mt-8 inline-flex items-center gap-2.5 rounded-full border border-line px-6 py-3.5 text-sm font-bold transition-colors hover:border-accent hover:text-accent"
>
<ShieldCheck class="size-4 text-accent" />
<VuesaxIcon name="shield-tick" class="size-4 text-accent" />
{{ standards.ctaLabel }}
</a>
</Reveal>
+4 -4
View File
@@ -11,18 +11,18 @@ const stats = computed(() => useSiteStore().sections.stats.items);
<div
v-for="(s, i) in stats"
:key="s.label"
class="group relative bg-bg p-8 transition-colors duration-500 hover:bg-panel md:p-10"
class="group relative bg-bg p-4 sm:p-8 transition-colors duration-500 hover:bg-panel md:p-10"
>
<span
class="absolute end-5 top-5 font-mono text-[10px] tracking-[0.25em] text-muted/50 transition-colors duration-500 group-hover:text-accent/70"
class="absolute end-3 top-3 sm:end-5 sm:top-5 font-mono text-[9px] sm:text-[10px] tracking-[0.25em] text-muted/50 transition-colors duration-500 group-hover:text-accent/70"
>
{{ s.note }}
</span>
<Reveal :delay="i * 0.08">
<div class="text-4xl font-black md:text-6xl">
<div class="text-2xl sm:text-4xl md:text-6xl font-black">
<CountUp :end="s.end" :suffix="s.suffix" />
</div>
<div class="mt-3 text-sm text-muted">{{ s.label }}</div>
<div class="mt-1.5 sm:mt-3 text-xs sm:text-sm text-muted">{{ s.label }}</div>
</Reveal>
<span
class="absolute bottom-0 start-0 h-0.5 w-0 bg-accent transition-all duration-500 group-hover:w-full"
@@ -1,26 +1,26 @@
<script setup lang="ts">
import { ShieldCheck } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const items = computed(() => useSiteStore().sections.tape.items);
</script>
<template>
<section aria-hidden="true" class="pointer-events-none relative z-20 overflow-hidden py-10">
<section aria-hidden="true" class="pointer-events-none relative z-20 overflow-hidden py-6 sm:py-10">
<div class="-rotate-[1.6deg] scale-[1.04]">
<div class="hazard h-2.5" />
<div dir="ltr" class="overflow-hidden bg-hazard py-3.5 text-bg">
<div class="hazard h-2 sm:h-2.5" />
<div dir="ltr" class="overflow-hidden bg-hazard py-2.5 sm:py-3.5 text-bg">
<div class="animate-marquee flex w-max">
<span
v-for="(item, i) in [...items, ...items]"
:key="i"
class="flex items-center gap-7 px-7 text-sm font-extrabold whitespace-nowrap"
class="flex items-center gap-5 sm:gap-7 px-5 sm:px-7 text-xs sm:text-sm font-extrabold whitespace-nowrap"
>
{{ item }}
<ShieldCheck class="size-4 shrink-0" :stroke-width="2.5" />
<VuesaxIcon name="shield-tick" class="size-3.5 sm:size-4 shrink-0" />
</span>
</div>
</div>
<div class="hazard h-2.5" />
<div class="hazard h-2 sm:h-2.5" />
</div>
</section>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { Quote } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const testimonials = computed(() => useSiteStore().sections.testimonials);
@@ -36,7 +36,7 @@ onBeforeUnmount(() => clearInterval(t));
<span
class="flex size-16 items-center justify-center rounded-full border border-accent/30 bg-accent/10"
>
<Quote class="size-7 text-accent" />
<VuesaxIcon name="quote-up" class="size-7 text-accent" />
</span>
<AnimatePresence mode="wait">
+3 -106
View File
@@ -1,112 +1,9 @@
<script setup lang="ts">
import type { IconName } from "@imen/shared";
import {
Award,
BadgeCheck,
Building2,
ClipboardCheck,
Clock,
Construction,
Ear,
Factory,
FileCheck,
FileSearch,
Flame,
Footprints,
Fuel,
Gauge,
Glasses,
Globe,
GraduationCap,
Hand,
Handshake,
HardHat,
Headset,
HeartPulse,
Leaf,
Lock,
Mail,
MapPin,
Medal,
Microscope,
Package,
PackageCheck,
Phone,
Pickaxe,
ShieldAlert,
ShieldCheck,
ShieldPlus,
Shirt,
Siren,
Star,
Target,
ThumbsUp,
TrafficCone,
TriangleAlert,
Truck,
Users,
Warehouse,
Wind,
Wrench,
Zap,
} from "lucide-vue-next";
import type { Component } from "vue";
import VuesaxIcon from "./VuesaxIcon.vue";
/** Explicit registry (matches ICON_NAMES in @imen/shared) so only these icons are bundled */
const ICONS: Record<IconName, Component> = {
ShieldCheck,
ShieldPlus,
ShieldAlert,
BadgeCheck,
Award,
Medal,
PackageCheck,
Package,
Truck,
Warehouse,
Globe,
Users,
Handshake,
Headset,
ClipboardCheck,
FileCheck,
FileSearch,
GraduationCap,
Gauge,
Microscope,
Wrench,
HardHat,
Shirt,
Footprints,
Hand,
Glasses,
Ear,
Wind,
TriangleAlert,
Siren,
TrafficCone,
Construction,
Factory,
Building2,
Fuel,
Pickaxe,
Flame,
Zap,
Leaf,
HeartPulse,
Clock,
Phone,
Mail,
MapPin,
Star,
ThumbsUp,
Target,
Lock,
};
defineProps<{ name: string }>();
defineProps<{ name: string; size?: number | string }>();
</script>
<template>
<component :is="ICONS[name as IconName] ?? ShieldCheck" />
<VuesaxIcon :name="name" :size="size" />
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ImageOff } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
withDefaults(
defineProps<{
@@ -17,7 +17,7 @@ withDefaults(
<div
:class="`flex flex-col items-center justify-center border border-dashed border-line bg-panel ${aspectClass}`"
>
<ImageOff class="size-10 text-muted/30" />
<VuesaxIcon name="gallery-slash" class="size-10 text-muted/30" />
<span class="mt-2 font-mono text-[10px] tracking-wider text-muted/40">{{ label }}</span>
</div>
</template>
+2 -2
View File
@@ -34,7 +34,7 @@ withDefaults(
<component
:is="`h${level}`"
:id="headingId"
class="mt-5 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]"
class="mt-3.5 sm:mt-5 text-2xl sm:text-3xl md:text-5xl font-black leading-tight sm:leading-[1.25] md:leading-[1.2]"
>
<slot name="title">
<Lines v-if="title" :text="title" />
@@ -46,7 +46,7 @@ withDefaults(
</component>
</Reveal>
<Reveal v-if="desc" :delay="0.16">
<p :class="`mt-5 max-w-xl leading-8 text-muted ${center ? 'mx-auto' : ''}`">
<p :class="`mt-3 sm:mt-5 max-w-xl text-xs sm:text-base leading-6 sm:leading-8 text-muted ${center ? 'mx-auto' : ''}`">
{{ desc }}
</p>
</Reveal>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from "vue";
import { resolveVuesaxIcon } from "@imen/shared";
import icons from "~/icons/vuesax-linear.json";
const props = defineProps<{
/** Short name (`add`), alias (`ShieldCheck`, `Phone`), or full key (`vuesax-linear:add`) */
name: string;
size?: number | string;
}>();
const pack = icons as Record<string, string>;
const svgHtml = computed(() => {
const resolved = resolveVuesaxIcon(props.name || "");
const key = resolved.includes(":") ? resolved : `vuesax-linear:${resolved}`;
const raw = pack[key] || pack[`vuesax-linear:${resolved.toLowerCase()}`] || pack["vuesax-linear:shield-tick"];
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(() => {
if (!props.size) return undefined;
return typeof props.size === "number" ? `${props.size}px` : props.size;
});
</script>
<template>
<span
class="anticon vx-icon"
:style="box ? { width: box, height: box } : undefined"
: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;
vertical-align: -0.15em;
}
.vx-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
</style>
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { NuxtError } from "#app";
import { ShieldAlert } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const props = defineProps<{ error: NuxtError }>();
const notFound = computed(() => props.error.statusCode === 404);
@@ -15,7 +15,7 @@ const goHome = () => clearError({ redirect: "/" });
<template>
<main class="flex min-h-screen flex-col items-center justify-center px-5 text-center">
<div class="hazard absolute inset-x-0 top-0 h-2.5" />
<ShieldAlert class="size-16 text-accent" />
<VuesaxIcon name="shield-cross" class="size-16 text-accent" />
<h1 class="mt-8 text-4xl font-black md:text-6xl">
{{ notFound ? "صفحه مورد نظر پیدا نشد" : "خطایی رخ داد" }}
</h1>
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -12,6 +12,7 @@ import Preloader from "~/components/layout/Preloader.vue";
* page that is being torn down, which crashed Vue's transition code.
*/
const ui = useUiStore();
const site = useSiteStore();
</script>
<template>
@@ -27,7 +28,7 @@ const ui = useUiStore();
<slot />
<Footer />
<Footer v-if="site.sections?.footer?.enabled !== false" />
<BackToTop />
</div>
</template>
+4 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { PublicCategory } from "@imen/shared";
import { ArrowLeft, ArrowRight, Phone } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import { categoryPageSchema } from "#shared/seo/schema";
import SectionHead from "~/components/ui/SectionHead.vue";
import Reveal from "~/components/ui/Reveal.vue";
@@ -215,7 +215,7 @@ const groupCount = (g: string) => category.products.filter((p) => p.group === g)
<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" />
<VuesaxIcon name="arrow-left" class="size-4 transition-transform group-hover:-translate-x-1" />
</span>
</div>
</NuxtLink>
@@ -231,14 +231,14 @@ const groupCount = (g: string) => category.products.filter((p) => p.group === g)
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" />
<VuesaxIcon name="arrow-right" 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" />
<VuesaxIcon name="call" class="size-4" />
درخواست قیمت
</a>
</div>
+20 -12
View File
@@ -11,31 +11,39 @@ import Testimonials from "~/components/sections/Testimonials.vue";
import Contact from "~/components/sections/Contact.vue";
const canonical = useAbsUrl()("/");
const { hero } = useSiteStore().sections;
const siteStore = useSiteStore();
const sections = computed(() => siteStore.sections);
const hero = computed(() => sections.value.hero);
useHead({
link: [
{ rel: "canonical", href: canonical },
{ rel: "preload", as: "image", href: hero.image.url, fetchpriority: "high" },
...(hero.value?.image?.url && hero.value.enabled !== false
? [{ rel: "preload" as const, as: "image" as const, href: hero.value.image.url, fetchpriority: "high" as const }]
: []),
],
});
useSeoMeta({
robots: "index, follow",
ogUrl: canonical,
});
onMounted(() => {
void siteStore.load();
});
</script>
<template>
<main>
<Hero />
<TapeMarquee />
<Stats />
<Products />
<About />
<Services />
<Standards />
<Industries />
<Testimonials />
<Contact />
<Hero v-if="sections.hero?.enabled !== false" />
<TapeMarquee v-if="sections.tape?.enabled !== false" />
<Stats v-if="sections.stats?.enabled !== false" />
<Products v-if="sections.productsIntro?.enabled !== false" />
<About v-if="sections.about?.enabled !== false" />
<Services v-if="sections.services?.enabled !== false" />
<Standards v-if="sections.standards?.enabled !== false" />
<Industries v-if="sections.industries?.enabled !== false" />
<Testimonials v-if="sections.testimonials?.enabled !== false" />
<Contact v-if="sections.contact?.enabled !== false" />
</main>
</template>
+15 -25
View File
@@ -1,17 +1,7 @@
<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 VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import { breadcrumbSchema, productSchema } from "#shared/seo/schema";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
@@ -221,7 +211,7 @@ useJsonLd(
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" />
<VuesaxIcon name="maximize-4" class="size-4" />
</button>
<template v-if="images.length > 1">
@@ -231,7 +221,7 @@ useJsonLd(
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" />
<VuesaxIcon name="arrow-right-1" class="size-5" />
</button>
<button
type="button"
@@ -239,7 +229,7 @@ useJsonLd(
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" />
<VuesaxIcon name="arrow-left-1" class="size-5" />
</button>
</template>
</div>
@@ -358,13 +348,13 @@ useJsonLd(
@click="requestQuote"
>
درخواست قیمت و موجودی
<ArrowLeft class="size-5 transition-transform duration-300 group-hover:-translate-x-1.5" />
<VuesaxIcon name="arrow-left" 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" />
<VuesaxIcon name="call" class="size-4" />
<span class="font-mono tracking-wider" dir="ltr">{{ settings.phoneDisplay }}</span>
</a>
</div>
@@ -412,7 +402,7 @@ useJsonLd(
<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" />
<VuesaxIcon name="tick-circle" class="size-3.5" />
</span>
<span class="text-sm leading-7 text-ink/85">{{ f }}</span>
</li>
@@ -471,7 +461,7 @@ useJsonLd(
<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" />
<VuesaxIcon name="24-support" 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">
@@ -505,7 +495,7 @@ useJsonLd(
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" />
<VuesaxIcon name="arrow-left" class="size-4 transition-transform group-hover:-translate-x-1" />
</NuxtLink>
</Reveal>
</div>
@@ -537,7 +527,7 @@ useJsonLd(
<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" />
<VuesaxIcon name="arrow-left" class="size-3.5 transition-transform group-hover:-translate-x-1" />
</span>
</div>
</NuxtLink>
@@ -553,7 +543,7 @@ useJsonLd(
: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" />
<VuesaxIcon name="arrow-right" class="size-4 transition-transform group-hover:translate-x-1" />
بازگشت به {{ product.category.name }}
</NuxtLink>
<NuxtLink
@@ -561,7 +551,7 @@ useJsonLd(
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" />
<VuesaxIcon name="call" class="size-4" />
درخواست قیمت این محصول
</NuxtLink>
</div>
@@ -595,7 +585,7 @@ useJsonLd(
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" />
<VuesaxIcon name="close-circle" 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">
@@ -607,7 +597,7 @@ useJsonLd(
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" />
<VuesaxIcon name="arrow-right-1" class="size-5" />
</button>
<button
type="button"
@@ -615,7 +605,7 @@ useJsonLd(
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" />
<VuesaxIcon name="arrow-left-1" class="size-5" />
</button>
</template>
</div>
+5 -1
View File
@@ -72,9 +72,13 @@ export default defineNuxtConfig({
},
},
build: {
transpile: ["ant-design-vue", "@ant-design/icons-vue", "@ctrl/tinycolor"],
},
vite: {
plugins: [tailwindcss()],
ssr: { noExternal: ["ant-design-vue", "@ant-design/icons-vue"] },
ssr: { noExternal: ["ant-design-vue", "@ant-design/icons-vue", "@ctrl/tinycolor"] },
},
typescript: { strict: true },
+2 -2
View File
@@ -2,13 +2,13 @@
# The apps themselves run on the host with `pnpm dev`.
services:
postgres:
image: docker.arvancloud.ir/postgres:18-alpine
image: postgres:15-alpine
environment:
POSTGRES_USER: imen
POSTGRES_PASSWORD: imen
POSTGRES_DB: imen
ports:
- "${POSTGRES_PORT:-5433}:5432"
- "${POSTGRES_PORT:-5435}:5432"
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+2 -1
View File
@@ -15,7 +15,8 @@
"infra:down": "docker compose -f docker-compose.dev.yml down",
"db:migrate": "pnpm --filter @imen/api prisma:migrate",
"db:seed": "pnpm --filter @imen/api db:seed",
"db:studio": "pnpm --filter @imen/api prisma:studio"
"db:studio": "pnpm --filter @imen/api prisma:studio",
"media:server": "node scripts/media-server.mjs"
},
"devDependencies": {
"prettier": "^3.6.2",
+16
View File
@@ -143,6 +143,18 @@ export interface Paginated<T> {
pageSize: number;
}
export interface DashboardCategoryStat {
id: number;
name: string;
count: number;
}
export interface DashboardTrendDay {
date: string;
dayName: string;
messages: number;
}
export interface DashboardDto {
categories: number;
products: number;
@@ -151,4 +163,8 @@ export interface DashboardDto {
media: number;
recentMessages: ContactMessageDto[];
recentActivity: { id: number; action: string; entity: string; summary: string; user: string | null; createdAt: string }[];
categoryStats: DashboardCategoryStat[];
messageBreakdown: { new: number; read: number; archived: number; total: number };
sectionsSummary: { total: number; visible: number; hidden: number };
trend: DashboardTrendDay[];
}
+1 -1
View File
@@ -15,7 +15,7 @@ export const slugSchema = z
.max(80)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "فقط حروف کوچک انگلیسی، عدد و خط تیره (مثلاً safety-shoes)");
export const iconNameSchema = z.enum(ICON_NAMES);
export const iconNameSchema = z.string().trim().min(1).max(80);
/** Image stored by URL inside section content */
export const imageRefSchema = z.object({
+152 -52
View File
@@ -1,56 +1,156 @@
/**
* Lucide icon names that content editors may pick in the admin panel.
* The web app keeps a matching registry (DynamicIcon) so only these icons end up in the bundle.
* Vuesax linear icon aliases and recommended picker list.
*/
export const ICON_NAMES = [
"ShieldCheck",
"ShieldPlus",
"ShieldAlert",
"BadgeCheck",
"Award",
"Medal",
"PackageCheck",
"Package",
"Truck",
"Warehouse",
"Globe",
"Users",
"Handshake",
"Headset",
"ClipboardCheck",
"FileCheck",
"FileSearch",
"GraduationCap",
"Gauge",
"Microscope",
"Wrench",
"HardHat",
"Shirt",
"Footprints",
"Hand",
"Glasses",
"Ear",
"Wind",
"TriangleAlert",
"Siren",
"TrafficCone",
"Construction",
"Factory",
"Building2",
"Fuel",
"Pickaxe",
"Flame",
"Zap",
"Leaf",
"HeartPulse",
"Clock",
"Phone",
"Mail",
"MapPin",
"Star",
"ThumbsUp",
"Target",
"Lock",
export const ICON_ALIASES: Record<string, string> = {
// Navigation & Actions
ArrowLeft: "arrow-left",
ArrowRight: "arrow-right",
ArrowUp: "arrow-up",
ArrowDown: "arrow-down",
ChevronDown: "arrow-down-1",
ChevronUp: "arrow-up-1",
ChevronLeft: "arrow-left-1",
ChevronRight: "arrow-right-1",
Menu: "menu",
X: "close-circle",
Plus: "add",
Trash: "trash",
Edit: "edit-2",
Copy: "copy",
Check: "tick-circle",
Search: "search-normal",
Filter: "filter",
Eye: "eye",
EyeOff: "eye-slash",
ImageOff: "gallery-slash",
Quote: "quote-up",
// Contact & Social
Phone: "call",
Mail: "sms",
MapPin: "location",
Clock: "clock",
Globe: "global",
Send: "send-2",
Instagram: "instagram",
Linkedin: "link",
MessageCircle: "messages-2",
Youtube: "video-play",
AtSign: "sms",
// Equipment & Safety Categories
HardHat: "security-safe",
Glasses: "glass",
Shirt: "tag",
Footprints: "routing-2",
Hand: "shield-tick",
Ear: "volume-high",
Wind: "wind-2",
TriangleAlert: "danger",
Siren: "lamp-on",
TrafficCone: "danger",
Construction: "setting-2",
Factory: "building-4",
Building2: "buildings-2",
Fuel: "gas-station",
Pickaxe: "hierarchy",
Flame: "flash",
Zap: "flash-circle",
Leaf: "tree",
HeartPulse: "heart-tick",
Star: "star",
ThumbsUp: "like-1",
Target: "radar-2",
Lock: "lock",
// Trust, Certifications & Standards
ShieldCheck: "shield-tick",
ShieldPlus: "shield-security",
ShieldAlert: "shield-cross",
BadgeCheck: "verify",
Award: "award",
Medal: "medal-star",
PackageCheck: "box-tick",
Package: "box",
Truck: "truck-time",
Warehouse: "buildings",
Users: "people",
Handshake: "user-tag",
Headset: "24-support",
ClipboardCheck: "clipboard-tick",
FileCheck: "document-text",
FileSearch: "document-filter",
GraduationCap: "teacher",
Gauge: "status",
Microscope: "search-status",
Wrench: "setting-4",
};
export function resolveVuesaxIcon(name: string): string {
if (!name) return "shield-tick";
if (name in ICON_ALIASES) return ICON_ALIASES[name];
return name.replace(/^vuesax-linear:/, "");
}
/**
* Recommended icon names displayed in pickers and dropdowns
*/
export const RECOMMENDED_ICONS = [
"shield-tick",
"shield-security",
"shield-cross",
"security-safe",
"security-user",
"verify",
"award",
"medal-star",
"box-tick",
"box",
"truck-time",
"buildings",
"building-4",
"buildings-2",
"global",
"people",
"user-tag",
"24-support",
"call",
"sms",
"location",
"clock",
"clipboard-tick",
"document-text",
"document-filter",
"teacher",
"status",
"search-status",
"setting-2",
"setting-4",
"glass",
"tag",
"routing-2",
"volume-high",
"wind-2",
"danger",
"lamp-on",
"gas-station",
"hierarchy",
"flash",
"flash-circle",
"tree",
"heart-tick",
"star",
"like-1",
"radar-2",
"lock",
"eye",
"gallery",
"video-play",
] as const;
export type IconName = (typeof ICON_NAMES)[number];
export const ICON_NAMES = [
...RECOMMENDED_ICONS,
...Object.keys(ICON_ALIASES),
] as const;
export type IconName = string;
+11
View File
@@ -77,6 +77,7 @@ export const settingsSectionSchema = z.object({
});
export const heroSectionSchema = z.object({
enabled: z.boolean().default(true),
badge: req(120),
title: req(80),
titleHighlight: req(80),
@@ -91,10 +92,12 @@ export const heroSectionSchema = z.object({
});
export const tapeSectionSchema = z.object({
enabled: z.boolean().default(true),
items: z.array(req(80)).min(1).max(20),
});
export const statsSectionSchema = z.object({
enabled: z.boolean().default(true),
items: z
.array(
z.object({
@@ -108,12 +111,14 @@ export const statsSectionSchema = z.object({
});
export const productsIntroSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema,
ctaLabel: req(60),
trust: z.array(iconCard).max(6),
});
export const aboutSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema,
body: req(2000),
image: imageRefSchema,
@@ -123,6 +128,7 @@ export const aboutSectionSchema = z.object({
});
export const servicesSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema,
items: z
.array(
@@ -137,6 +143,7 @@ export const servicesSectionSchema = z.object({
});
export const standardsSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema,
note: text(500),
ctaLabel: req(80),
@@ -144,6 +151,7 @@ export const standardsSectionSchema = z.object({
});
export const industriesSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema,
items: z
.array(
@@ -158,11 +166,13 @@ export const industriesSectionSchema = z.object({
});
export const testimonialsSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema,
items: z.array(z.object({ text: req(600), name: req(80), role: text(160) })).max(12),
});
export const contactSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema,
emergencyTitle: req(80),
emergencyText: req(400),
@@ -173,6 +183,7 @@ export const contactSectionSchema = z.object({
});
export const footerSectionSchema = z.object({
enabled: z.boolean().default(true),
about: req(500),
copyright: req(160),
certLine: text(160),
+119
View File
@@ -0,0 +1,119 @@
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "../.s3data/imen-media");
if (!fs.existsSync(ROOT_DIR)) {
fs.mkdirSync(ROOT_DIR, { recursive: true });
}
const MIME_TYPES = {
".webp": "image/webp",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".json": "application/json",
};
const server = http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
// Parse path and remove query string
const urlObj = new URL(req.url, "http://localhost:9000");
let pathname = decodeURIComponent(urlObj.pathname);
// Normalize path by stripping optional leading bucket name "/imen-media" or "/media"
if (pathname.startsWith("/imen-media")) {
pathname = pathname.slice("/imen-media".length);
} else if (pathname.startsWith("/media")) {
pathname = pathname.slice("/media".length);
}
if (pathname === "" || pathname === "/") {
res.writeHead(200, { "Content-Type": "application/xml" });
res.end("<BucketResponse>OK</BucketResponse>");
return;
}
const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, "");
const filePath = path.join(ROOT_DIR, safePath);
if (req.method === "HEAD") {
if (fs.existsSync(filePath)) {
const stat = fs.statSync(filePath);
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, {
"Content-Type": MIME_TYPES[ext] || "application/octet-stream",
"Content-Length": stat.size,
});
res.end();
} else {
// Bucket check or file check
res.writeHead(200);
res.end();
}
return;
}
if (req.method === "GET") {
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
res.writeHead(200, {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
});
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end(`File not found: ${pathname}`);
}
return;
}
if (req.method === "PUT") {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const writeStream = fs.createWriteStream(filePath);
req.pipe(writeStream);
writeStream.on("finish", () => {
res.writeHead(200, { ETag: '"local-etag"' });
res.end();
});
writeStream.on("error", (err) => {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end(err.message);
});
return;
}
if (req.method === "DELETE") {
if (fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
} catch {}
}
res.writeHead(204);
res.end();
return;
}
res.writeHead(405);
res.end();
});
const PORT = 9000;
server.listen(PORT, "0.0.0.0", () => {
console.log(`Media storage server listening on http://0.0.0.0:${PORT} (serving ${ROOT_DIR})`);
});