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 # database dumps from the backup service
backups/ 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]; data: SiteSections[K];
updatedAt: string; 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; flex-direction: column;
overflow: hidden; 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> </style>
+9 -11
View File
@@ -1,20 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ICON_NAMES, type IconName } from "@imen/shared"; import { RECOMMENDED_ICONS } from "@imen/shared";
import { computed } from "vue"; import VuesaxIcon from "./VuesaxIcon.vue";
import { ICONS } from "./icons";
/** Picks one of the Lucide icons the website knows how to render */
const value = defineModel<string>({ required: true }); const value = defineModel<string>({ required: true });
defineProps<{ allowClear?: boolean }>(); defineProps<{ allowClear?: boolean }>();
const options = ICON_NAMES.map((name) => ({ value: name, label: name })); const options = RECOMMENDED_ICONS.map((name) => ({ value: name, label: name }));
const current = computed(() => ICONS[value.value as IconName]);
</script> </script>
<template> <template>
<div class="flex items-center gap-2"> <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]"> <span class="flex size-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-[#ff5a00]">
<component :is="current" v-if="current" class="size-4" /> <VuesaxIcon v-if="value" :name="value" :size="20" />
<span v-else class="text-xs text-gray-400">---</span>
</span> </span>
<a-select <a-select
v-model:value="value" v-model:value="value"
@@ -25,9 +23,9 @@ const current = computed(() => ICONS[value.value as IconName]);
placeholder="انتخاب آیکون" placeholder="انتخاب آیکون"
> >
<template #option="{ value: name }"> <template #option="{ value: name }">
<span class="flex items-center gap-2" dir="ltr"> <span class="flex items-center gap-2.5 py-0.5" dir="ltr">
<component :is="ICONS[name as IconName]" class="size-4" /> <VuesaxIcon :name="name" :size="18" class="text-orange-600" />
{{ name }} <span class="text-xs font-mono">{{ name }}</span>
</span> </span>
</template> </template>
</a-select> </a-select>
+31 -8
View File
@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import VuesaxIcon from "@/components/VuesaxIcon.vue";
defineProps<{ title: string; subtitle?: string; back?: string }>(); defineProps<{ title: string; subtitle?: string; back?: string }>();
</script> </script>
<template> <template>
<div class="page-header"> <div class="page-header">
<div class="page-header__main"> <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"> <div class="min-w-0">
<h1 class="page-header__title">{{ title }}</h1> <h1 class="page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p> <p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
@@ -19,42 +23,61 @@ defineProps<{ title: string; subtitle?: string; back?: string }>();
<style scoped> <style scoped>
.page-header { .page-header {
flex: 0 0 auto !important;
flex-shrink: 0 !important;
flex-grow: 0 !important;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 10px;
} }
.page-header__main { .page-header__main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
min-width: 0; min-width: 0;
} }
.page-header__back { .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; text-decoration: none;
transition: color 0.15s ease; transition: all 0.15s ease;
flex-shrink: 0;
} }
.page-header__back:hover { .page-header__back:hover {
color: #334155; background: #e2e8f0;
color: #0f172a;
} }
.page-header__title { .page-header__title {
margin: 0; margin: 0;
font-size: 18px; font-size: 16px;
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
color: #0f172a; color: #0f172a;
} }
@media (min-width: 640px) {
.page-header__title {
font-size: 18px;
}
}
.page-header__subtitle { .page-header__subtitle {
margin: 2px 0 0; margin: 2px 0 0;
font-size: 13px; font-size: 12px;
color: #94a3b8; color: #94a3b8;
line-height: 1.4;
} }
.page-header__actions { .page-header__actions {
+7 -6
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import { resolveVuesaxIcon } from "@imen/shared";
import icons from "@/icons/vuesax-linear.json"; import icons from "@/icons/vuesax-linear.json";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
/** Short name (`add`) or full key (`vuesax-linear:add`) */ /** Short name (`add`), alias (`ShieldCheck`, `Phone`), or full key (`vuesax-linear:add`) */
name: string; name: string;
size?: number | string; size?: number | string;
}>(), }>(),
@@ -14,10 +15,9 @@ const props = withDefaults(
const pack = icons as Record<string, string>; const pack = icons as Record<string, string>;
const svgHtml = computed(() => { const svgHtml = computed(() => {
const key = props.name.startsWith("vuesax-linear:") const resolved = resolveVuesaxIcon(props.name || "");
? props.name const key = resolved.includes(":") ? resolved : `vuesax-linear:${resolved}`;
: `vuesax-linear:${props.name}`; const raw = pack[key] || pack[`vuesax-linear:${resolved.toLowerCase()}`] || pack["vuesax-linear:shield-tick"];
const raw = pack[key];
if (!raw) return ""; if (!raw) return "";
return raw return raw
.replace(/\swidth="24"/, "") .replace(/\swidth="24"/, "")
@@ -33,7 +33,7 @@ const box = computed(() =>
<template> <template>
<span <span
class="vx-icon" class="anticon vx-icon"
:style="{ width: box, height: box }" :style="{ width: box, height: box }"
:aria-hidden="true" :aria-hidden="true"
v-html="svgHtml" v-html="svgHtml"
@@ -48,6 +48,7 @@ const box = computed(() =>
justify-content: center; justify-content: center;
line-height: 0; line-height: 0;
color: inherit; color: inherit;
vertical-align: -0.15em;
} }
.vx-icon :deep(svg) { .vx-icon :deep(svg) {
+2 -104
View File
@@ -1,104 +1,2 @@
import type { IconName } from "@imen/shared"; export { ICON_ALIASES, ICON_NAMES, RECOMMENDED_ICONS, resolveVuesaxIcon } from "@imen/shared";
import { export type { IconName } from "@imen/shared";
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,
};
+509 -68
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ROLE_LABELS } from "@imen/shared"; 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 { useRoute, useRouter } from "vue-router";
import { api } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue"; import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { SITE_URL } from "@/utils/format"; import { SITE_URL } from "@/utils/format";
@@ -10,13 +11,28 @@ const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const drawer = ref(false); 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) */ /** Icon badge box + Vuesax glyph (keeps pre-swap menu structure) */
const menuIcon = (name: string) => () => 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(() => [ 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: "products", icon: menuIcon("bag-2"), label: "محصولات" },
{ key: "categories", icon: menuIcon("category"), label: "دسته‌بندی‌ها" }, { key: "categories", icon: menuIcon("category"), label: "دسته‌بندی‌ها" },
{ key: "sections", icon: menuIcon("row-vertical"), label: "بخش‌های سایت" }, { key: "sections", icon: menuIcon("row-vertical"), label: "بخش‌های سایت" },
@@ -58,16 +74,35 @@ const selected = computed(() => {
const pageTitle = computed(() => PAGE_TITLES[String(route.name ?? "")] ?? "پنل مدیریت"); const pageTitle = computed(() => PAGE_TITLES[String(route.name ?? "")] ?? "پنل مدیریت");
const userInitial = computed(() => (auth.user?.name?.trim().charAt(0) || "ک").slice(0, 1)); 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 }) => { const go = ({ key }: { key: string | number }) => {
drawer.value = false; drawer.value = false;
void router.push({ name: String(key) }); void router.push({ name: String(key) });
}; };
const navigateTo = (name: string) => {
drawer.value = false;
void router.push({ name });
};
watch(() => route.fullPath, () => (drawer.value = false)); watch(() => route.fullPath, () => (drawer.value = false));
</script> </script>
<template> <template>
<a-layout class="admin-shell"> <a-layout class="admin-shell">
<!-- Desktop Sider (lg and up) -->
<a-layout-sider <a-layout-sider
:width="248" :width="248"
breakpoint="lg" breakpoint="lg"
@@ -97,42 +132,250 @@ watch(() => route.fullPath, () => (drawer.value = false));
</div> </div>
</a-layout-sider> </a-layout-sider>
<!-- Mobile App Hub Drawer (Rich App Grid) -->
<a-drawer <a-drawer
v-model:open="drawer" v-model:open="drawer"
placement="right" placement="right"
:width="280" :width="320"
:closable="false" :closable="false"
class="admin-drawer" class="mobile-hub-drawer"
:body-style="{ padding: 0, background: '#ffffff' }" :body-style="{ padding: 0, background: '#f8fafc' }"
> >
<div class="admin-sider__brand"> <div class="flex flex-col h-full">
<span class="admin-sider__logo"> <!-- Hub User Profile Header -->
<img src="/logo.png" alt="" class="size-7 object-contain" /> <div class="p-4 bg-white border-b border-slate-200/80">
</span> <div class="flex items-center justify-between mb-3">
<div class="leading-tight"> <div class="flex items-center gap-2.5">
<div class="text-[14px] font-bold text-[#0f172a]">ایمن پیشگامان</div> <span class="size-8 rounded-lg bg-orange-50 border border-orange-100 flex items-center justify-center">
<div class="text-[10px] font-medium text-[#94a3b8]">پنل مدیریت</div> <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>
<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> </div>
</a-drawer> </a-drawer>
<a-layout> <a-layout>
<a-layout-header class="admin-header"> <!-- Dedicated Mobile App Bar (Mobile Only) -->
<div class="admin-header__start"> <header class="mobile-app-bar lg:!hidden">
<a-button class="admin-header__menu-btn lg:!hidden" @click="drawer = true"> <div v-if="isChildRoute" class="flex items-center justify-between w-full">
<template #icon><VuesaxIcon name="menu" :size="18" /></template> <button
</a-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"> <div class="admin-header__crumb">
<span class="admin-header__crumb-kicker">پنل مدیریت</span> <span class="admin-header__crumb-kicker">پنل مدیریت</span>
<span class="admin-header__crumb-sep" aria-hidden="true">/</span> <span class="admin-header__crumb-sep" aria-hidden="true">/</span>
@@ -179,13 +422,69 @@ watch(() => route.fullPath, () => (drawer.value = false));
</div> </div>
</a-layout-header> </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"> <div class="admin-page">
<RouterView v-slot="{ Component, route: r }"> <RouterView v-slot="{ Component, route: r }">
<component :is="Component" :key="r.path" /> <component :is="Component" :key="r.path" />
</RouterView> </RouterView>
</div> </div>
</a-layout-content> </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>
</a-layout> </a-layout>
</template> </template>
@@ -247,14 +546,22 @@ watch(() => route.fullPath, () => (drawer.value = false));
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); 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; flex: 1;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
/* Pages that don't use AdminPage still scroll inside the panel */ /* 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; overflow: auto;
padding: 16px; padding: 16px;
box-sizing: border-box; box-sizing: border-box;
@@ -276,8 +583,9 @@ watch(() => route.fullPath, () => (drawer.value = false));
.admin-header__menu-btn { .admin-header__menu-btn {
border-radius: 10px !important; border-radius: 10px !important;
border-color: #e2e8f0 !important; border: none !important;
color: #475569 !important; color: #475569 !important;
background: transparent !important;
} }
.admin-header__crumb { .admin-header__crumb {
@@ -325,26 +633,21 @@ watch(() => route.fullPath, () => (drawer.value = false));
align-items: center; align-items: center;
gap: 8px; gap: 8px;
height: 36px; height: 36px;
padding-inline: 14px; padding-inline: 8px;
border: 1px solid #e2e8f0; border: none !important;
border-radius: 999px; background: transparent !important;
background: #ffffff;
color: #475569; color: #475569;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
transition: transition: color 0.18s ease;
color 0.18s ease,
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
} }
.admin-header__site:hover { .admin-header__site:hover {
color: #e04f00; color: #ff5a00;
border-color: #ffd0b5; border: none !important;
background: #fff7f2; background: transparent !important;
box-shadow: 0 2px 8px rgba(255, 90, 0, 0.08); box-shadow: none !important;
} }
.admin-header__divider { .admin-header__divider {
@@ -362,7 +665,7 @@ watch(() => route.fullPath, () => (drawer.value = false));
padding-block: 4px; padding-block: 4px;
padding-inline: 4px 10px; padding-inline: 4px 10px;
border: 1px solid #e8ecf2; border: 1px solid #e8ecf2;
border-radius: 999px; border-radius: var(--ant-border-radius, 8px);
background: #f8fafc; background: #f8fafc;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
@@ -385,7 +688,7 @@ watch(() => route.fullPath, () => (drawer.value = false));
width: 34px; width: 34px;
height: 34px; height: 34px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 999px; border-radius: calc(var(--ant-border-radius, 8px) - 2px);
background: linear-gradient(135deg, #ff5a00, #ff8a3d); background: linear-gradient(135deg, #ff5a00, #ff8a3d);
color: #ffffff; color: #ffffff;
font-size: 14px; font-size: 14px;
@@ -498,14 +801,14 @@ watch(() => route.fullPath, () => (drawer.value = false));
.admin-menu :deep(.ant-menu-item) { .admin-menu :deep(.ant-menu-item) {
display: flex; display: flex;
align-items: center; align-items: center;
height: 44px !important; height: 48px !important;
margin: 0 0 6px !important; margin: 0 0 6px !important;
padding-inline: 12px !important; padding-inline: 10px 14px !important;
border: 1px solid transparent !important; border: 1px solid transparent !important;
border-radius: 12px !important; border-radius: 12px !important;
color: #475569 !important; color: #475569 !important;
font-weight: 500; font-weight: 500;
line-height: 44px !important; line-height: 48px !important;
transition: transition:
background 0.2s ease, background 0.2s ease,
color 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-icon),
.admin-menu :deep(.ant-menu-item .anticon) { .admin-menu :deep(.ant-menu-item .anticon) {
display: inline-flex !important; display: inline-flex !important;
align-items: center; align-items: center !important;
justify-content: center; justify-content: center !important;
width: auto !important; width: 36px !important;
height: auto !important; height: 36px !important;
margin-inline-end: 0 !important; min-width: 36px !important;
min-height: 36px !important;
margin-inline-end: 12px !important;
font-size: 0 !important; font-size: 0 !important;
background: transparent !important; background: transparent !important;
box-shadow: none !important; box-shadow: none !important;
@@ -528,12 +833,14 @@ watch(() => route.fullPath, () => (drawer.value = false));
.admin-menu :deep(.admin-menu__icon) { .admin-menu :deep(.admin-menu__icon) {
display: inline-flex !important; display: inline-flex !important;
align-items: center; align-items: center !important;
justify-content: center; justify-content: center !important;
width: 30px; width: 36px !important;
height: 30px; height: 36px !important;
margin-inline-end: 10px; min-width: 36px !important;
border-radius: 9px; min-height: 36px !important;
flex-shrink: 0 !important;
border-radius: 10px !important;
background: #f1f5f9; background: #f1f5f9;
color: #64748b; color: #64748b;
transition: transition:
@@ -543,11 +850,22 @@ watch(() => route.fullPath, () => (drawer.value = false));
} }
.admin-menu :deep(.admin-menu__icon .vx-icon) { .admin-menu :deep(.admin-menu__icon .vx-icon) {
width: 16px !important; display: inline-flex !important;
height: 16px !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; background: transparent !important;
box-shadow: none !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) { .admin-menu :deep(.ant-menu-item:hover) {
@@ -556,9 +874,9 @@ watch(() => route.fullPath, () => (drawer.value = false));
border-color: #eef1f6 !important; border-color: #eef1f6 !important;
} }
.admin-menu :deep(.ant-menu-item:hover .admin-menu__icon) { .admin-menu :deep(.ant-menu-item:hover:not(.ant-menu-item-selected) .admin-menu__icon) {
background: #e8eef7; background: #e8eef7 !important;
color: #475569; color: #475569 !important;
} }
.admin-menu :deep(.ant-menu-item-selected) { .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) { .admin-menu :deep(.ant-menu-item-selected .admin-menu__icon) {
background: #ff5a00 !important; background: #ff5a00 !important;
color: #ffffff !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) { .admin-menu :deep(.ant-menu-item-selected::after) {
display: none !important; 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> </style>
+132 -1
View File
@@ -5,8 +5,10 @@ import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue"; import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { AdminCategory } from "@/api/types"; import type { AdminCategory } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { faNumber, SITE_URL } from "@/utils/format"; import { faNumber, SITE_URL } from "@/utils/format";
const auth = useAuthStore();
const rows = ref<AdminCategory[]>([]); const rows = ref<AdminCategory[]>([]);
const loading = ref(false); const loading = ref(false);
@@ -67,7 +69,7 @@ onMounted(load);
</RouterLink> </RouterLink>
</PageHeader> </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 }"> <a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 800 }">
<template #bodyCell="{ column, record, index }"> <template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'cover'"> <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-button size="small" title="مشاهده در سایت"><template #icon><VuesaxIcon name="export" :size="16" /></template></a-button>
</a> </a>
<a-popconfirm <a-popconfirm
v-if="auth.isAdmin"
title="این دسته حذف شود؟" title="این دسته حذف شود؟"
ok-text="حذف" ok-text="حذف"
cancel-text="انصراف" cancel-text="انصراف"
@@ -122,4 +125,132 @@ onMounted(load);
</template> </template>
</a-table> </a-table>
</a-card> </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> </template>
+72 -7
View File
@@ -102,6 +102,8 @@ const groupTitle = (g: Group) => {
return count ? `${g.name} (${count} محصول)` : g.name; return count ? `${g.name} (${count} محصول)` : g.name;
}; };
const mobileTab = ref<"main" | "groups" | "media" | "seo">("main");
onMounted(load); onMounted(load);
</script> </script>
@@ -113,11 +115,50 @@ onMounted(load);
</a-button> </a-button>
</PageHeader> </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-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="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2"> <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"> <div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام دسته" required> <FormField path="name" label="نام دسته" required>
<a-input v-model:value="model.name" /> <a-input v-model:value="model.name" />
@@ -144,7 +185,7 @@ onMounted(load);
</div> </div>
</a-card> </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 class="mb-4 text-sm text-gray-500">
زیرگروه‌ها در صفحه دسته به‌صورت فیلتر نمایش داده می‌شوند. با حذف یک زیرگروه، محصولات آن بدون زیرگروه می‌مانند. زیرگروه‌ها در صفحه دسته به‌صورت فیلتر نمایش داده می‌شوند. با حذف یک زیرگروه، محصولات آن بدون زیرگروه می‌مانند.
</p> </p>
@@ -172,23 +213,23 @@ onMounted(load);
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
<a-card title="انتشار"> <a-card title="انتشار" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="isPublished"> <FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" /> <a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "مخفی" }}</span> <span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "مخفی" }}</span>
</FormField> </FormField>
</a-card> </a-card>
<a-card title="تصویر کاور"> <a-card title="تصویر کاور" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="coverImageId"> <FormField path="coverImageId">
<ImageField :url="cover?.url" clearable @pick="pickCover" @clear="cover = null; model.coverImageId = null" /> <ImageField :url="cover?.url" clearable @pick="pickCover" @clear="cover = null; model.coverImageId = null" />
</FormField> </FormField>
</a-card> </a-card>
<a-card title="آیکون"> <a-card title="آیکون" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="icon"> <FormField path="icon">
<IconSelect v-model="model.icon" allow-clear /> <IconSelect v-model="model.icon" allow-clear />
</FormField> </FormField>
</a-card> </a-card>
<a-card title="سئو"> <a-card title="سئو" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام دسته"> <FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام دسته">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count /> <a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField> </FormField>
@@ -200,4 +241,28 @@ onMounted(load);
</div> </div>
</a-form> </a-form>
</a-spin> </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> </template>
+290 -36
View File
@@ -1,84 +1,338 @@
<script setup lang="ts"> <script setup lang="ts">
import { type DashboardDto, MESSAGE_STATUS_LABELS } from "@imen/shared"; import { type DashboardDto, MESSAGE_STATUS_LABELS } from "@imen/shared";
import { message } from "ant-design-vue"; import { message } from "ant-design-vue";
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http"; import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue"; import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { activityLabel, faNumber, jalali } from "@/utils/format"; import { activityLabel, faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const data = ref<DashboardDto | null>(null); 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 () => { onMounted(async () => {
loading.value = true;
try { try {
data.value = await api<DashboardDto>("/admin/dashboard"); data.value = await api<DashboardDto>("/admin/dashboard");
} catch (e) { } catch (e) {
void message.error(errorMessage(e)); void message.error(errorMessage(e));
} finally {
loading.value = false;
} }
}); });
</script> </script>
<template> <template>
<PageHeader title="داشبورد" subtitle="خلاصه وضعیت محتوای سایت"> <PageHeader title="داشبورد مدیریت" subtitle="نمای کلی آمار، نمودارها، پیام‌ها و فعالیت‌های اخیر سامانه">
<RouterLink :to="{ name: 'product-new' }"> <div class="flex items-center gap-2">
<a-button type="primary"> <RouterLink :to="{ name: 'sections' }">
<template #icon><VuesaxIcon name="add" :size="16" /></template> <a-button>
محصول جدید <template #icon><VuesaxIcon name="row-vertical" :size="16" /></template>
</a-button> مدیریت بخش‌ها
</RouterLink> </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> </PageHeader>
<a-spin :spinning="!data"> <a-spin :spinning="loading">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5"> <!-- 1. Top Statistics Row -->
<RouterLink :to="{ name: 'products' }"> <div class="grid grid-cols-2 gap-3 lg:gap-4 lg:grid-cols-5">
<a-card hoverable><a-statistic title="محصولات" :value="faNumber(data?.products ?? 0)" /></a-card> <RouterLink :to="{ name: 'products' }" class="active:scale-[0.98] transition-transform">
</RouterLink> <a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<RouterLink :to="{ name: 'products', query: { status: 'draft' } }"> <div class="flex items-center justify-between">
<a-card hoverable><a-statistic title="پیش‌نویس" :value="faNumber(data?.draftProducts ?? 0)" /></a-card> <a-statistic title="کل محصولات" :value="faNumber(data?.products ?? 0)" />
</RouterLink> <div class="flex size-10 items-center justify-center rounded-xl bg-orange-50 text-orange-600">
<RouterLink :to="{ name: 'categories' }"> <VuesaxIcon name="bag-2" :size="20" />
<a-card hoverable><a-statistic title="دسته‌بندی‌ها" :value="faNumber(data?.categories ?? 0)" /></a-card> </div>
</RouterLink> </div>
<RouterLink :to="{ name: 'messages', query: { status: 'NEW' } }"> <div class="mt-2 text-xs text-gray-400">
<a-card hoverable> {{ faNumber((data?.products ?? 0) - (data?.draftProducts ?? 0)) }} محصول فعال روی سایت
<a-statistic </div>
title="پیام‌های جدید"
:value="faNumber(data?.newMessages ?? 0)"
:value-style="data?.newMessages ? { color: '#ff5a00' } : undefined"
/>
</a-card> </a-card>
</RouterLink> </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> </RouterLink>
</div> </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"> <div class="mt-6 grid gap-6 lg:grid-cols-2">
<!-- Recent Messages -->
<a-card title="آخرین پیام‌های تماس"> <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-empty v-if="!data?.recentMessages.length" description="پیامی ثبت نشده است" />
<a-list v-else :data-source="data.recentMessages" item-layout="horizontal"> <a-list v-else :data-source="data.recentMessages" item-layout="horizontal">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-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"> <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> <a-tag :color="item.status === 'NEW' ? 'orange' : item.status === 'READ' ? 'blue' : 'default'">
<div class="mt-1">{{ jalali(item.createdAt) }}</div> {{ 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> </div>
</a-list-item> </a-list-item>
</template> </template>
</a-list> </a-list>
</a-card> </a-card>
<a-card title="آخرین فعالیت‌ها"> <!-- Recent Audit Log / Activity -->
<a-card title="آخرین فعالیت‌ها و رویدادهای سیستم">
<a-empty v-if="!data?.recentActivity.length" description="فعالیتی ثبت نشده است" /> <a-empty v-if="!data?.recentActivity.length" description="فعالیتی ثبت نشده است" />
<a-timeline v-else class="!mt-2"> <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"> <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"> <div class="text-sm">
<b>{{ activityLabel(a.action, a.entity) }}</b> <b>{{ activityLabel(a.action, a.entity) }}</b>
<span v-if="a.summary" class="text-gray-600"> — {{ a.summary }}</span> <span v-if="a.summary" class="text-gray-600"> — {{ a.summary }}</span>
</div> </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-item>
</a-timeline> </a-timeline>
</a-card> </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) { .login-panel :deep(.ant-form-item-label > label) {
color: #edede6 !important; color: #edede6 !important;
} }
@@ -187,6 +191,12 @@ async function submit() {
color: #edede6 !important; 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:hover),
.login-panel :deep(.ant-input-affix-wrapper-focused), .login-panel :deep(.ant-input-affix-wrapper-focused),
.login-panel :deep(.ant-input-affix-wrapper:focus-within) { .login-panel :deep(.ant-input-affix-wrapper:focus-within) {
@@ -198,10 +208,23 @@ async function submit() {
color: #8e9ab0 !important; 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; 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) { .login-panel :deep(.ant-form-item-explain-error) {
color: #ff7a2a; color: #ff7a2a;
} }
+110 -21
View File
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MediaDto, Paginated } from "@imen/shared"; import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue"; import { message } from "ant-design-vue";
import { onMounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { api, errorMessage } from "@/api/http"; import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue"; import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { faNumber, fileSize, jalali } from "@/utils/format"; import { faNumber, fileSize, jalali } from "@/utils/format";
const auth = useAuthStore();
const items = ref<MediaDto[]>([]); const items = ref<MediaDto[]>([]);
const total = ref(0); const total = ref(0);
const page = ref(1); const page = ref(1);
@@ -16,6 +18,13 @@ const uploading = ref(0);
const selected = ref<MediaDto | null>(null); const selected = ref<MediaDto | null>(null);
const altDraft = ref(""); const altDraft = ref("");
const PAGE_SIZE = 36; const PAGE_SIZE = 36;
const isMobile = ref(false);
function updateMobile() {
if (typeof window !== "undefined") {
isMobile.value = window.innerWidth < 1024;
}
}
async function load() { async function load() {
loading.value = true; loading.value = true;
@@ -82,7 +91,17 @@ async function copyUrl(url: string) {
void message.success("آدرس کپی شد"); void message.success("آدرس کپی شد");
} }
onMounted(load); onMounted(() => {
updateMobile();
window.addEventListener("resize", updateMobile);
void load();
});
onUnmounted(() => {
if (typeof window !== "undefined") {
window.removeEventListener("resize", updateMobile);
}
});
</script> </script>
<template> <template>
@@ -100,51 +119,121 @@ onMounted(load);
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو در نام فایل یا متن جایگزین" placeholder="جستجو در نام فایل یا متن جایگزین"
class="mb-4 max-w-md" class="mb-4 w-full md:max-w-md"
@search="() => { page = 1; load(); }" @search="() => { page = 1; load(); }"
/> />
<a-spin :spinning="loading"> <a-spin :spinning="loading">
<a-empty v-if="!items.length && !loading" description="تصویری پیدا نشد" /> <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 <button
v-for="m in items" v-for="m in items"
:key="m.id" :key="m.id"
type="button" 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" :title="m.originalName"
@click="open(m)" @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> </button>
</div> </div>
</a-spin> </a-spin>
<div class="mt-4 flex justify-center"> <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> </div>
</a-card> </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"> <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" /> <div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 flex items-center justify-center p-2">
<a-descriptions :column="1" size="small" class="mt-4"> <img :src="selected.url" :alt="selected.alt" class="max-h-60 w-full object-contain" />
<a-descriptions-item label="نام فایل"><span dir="ltr">{{ selected.originalName }}</span></a-descriptions-item> </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="ابعاد"> <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>
<a-descriptions-item label="حجم">{{ fileSize(selected.size) }}</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-descriptions>
<a-form layout="vertical" class="mt-4"> <a-form layout="vertical" class="mt-4">
<a-form-item label="متن جایگزین (alt)"> <a-form-item label="متن جایگزین (alt)" class="!mb-3">
<a-input v-model:value="altDraft" /> <a-input v-model:value="altDraft" placeholder="توضیح کوتاه برای سئو و دسترس‌پذیری" />
</a-form-item> </a-form-item>
<a-space wrap>
<a-button type="primary" @click="saveAlt">ذخیره</a-button> <!-- Actions: Desktop row vs Mobile full touch grid -->
<a-button @click="copyUrl(selected.url)"><template #icon><VuesaxIcon name="copy" :size="16" /></template>کپی آدرس</a-button> <div class="max-lg:grid max-lg:grid-cols-2 max-lg:gap-2 max-lg:mt-4 max-lg:hidden">
<a-popconfirm title="این تصویر حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove"> <a-space wrap>
<a-button danger><template #icon><VuesaxIcon name="trash" :size="16" /></template>حذف</a-button> <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-popconfirm>
</a-space> </div>
</a-form> </a-form>
</template> </template>
</a-drawer> </a-drawer>
+172 -6
View File
@@ -7,13 +7,15 @@ import {
type Paginated, type Paginated,
} from "@imen/shared"; } from "@imen/shared";
import { message } from "ant-design-vue"; 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 { useRoute } from "vue-router";
import { api, errorMessage } from "@/api/http"; import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue"; import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { faNumber, jalali } from "@/utils/format"; import { faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
/** "ALL" or one status */ /** "ALL" or one status */
const status = ref<MessageStatus | "ALL">((route.query.status as MessageStatus) || "ALL"); const status = ref<MessageStatus | "ALL">((route.query.status as MessageStatus) || "ALL");
@@ -23,8 +25,11 @@ const page = ref(1);
const rows = ref<ContactMessageDto[]>([]); const rows = ref<ContactMessageDto[]>([]);
const total = ref(0); const total = ref(0);
const loading = ref(false); const loading = ref(false);
const expandedIds = ref<number[]>([]);
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const totalPages = computed(() => Math.ceil(total.value / PAGE_SIZE) || 1);
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
@@ -44,11 +49,24 @@ async function setStatus(row: ContactMessageDto, s: MessageStatus) {
try { try {
await api(`/admin/messages/${row.id}`, { method: "PATCH", body: { status: s } }); await api(`/admin/messages/${row.id}`, { method: "PATCH", body: { status: s } });
row.status = s; row.status = s;
void message.success(`وضعیت پیام به «${MESSAGE_STATUS_LABELS[s]}» تغییر کرد`);
} catch (e) { } catch (e) {
void message.error(errorMessage(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 */ /** opening a new message marks it as read */
function onExpand(expanded: boolean, row: ContactMessageDto) { function onExpand(expanded: boolean, row: ContactMessageDto) {
if (expanded && row.status === "NEW") void setStatus(row, "READ"); 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 STATUS_COLORS: Record<MessageStatus, string> = { NEW: "orange", READ: "blue", ARCHIVED: "default" };
const columns = [ const columns = [
{ title: "نام", dataIndex: "name" }, { title: "نام", dataIndex: "name" },
@@ -82,11 +106,28 @@ onMounted(load);
</script> </script>
<template> <template>
<PageHeader title="پیام‌های تماس" :subtitle="`${faNumber(total)} پیام — درخواست‌های ثبت‌شده از فرم مشاوره سایت`"> <PageHeader title="پیام‌های تماس" :subtitle="`${faNumber(total)} پیام — استعلام‌ها و درخواست‌های ثبت‌شده از سایت`">
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" /> <div class="!hidden lg:!block">
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
</div>
</PageHeader> </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 <a-table
:columns="columns" :columns="columns"
:data-source="rows" :data-source="rows"
@@ -101,7 +142,7 @@ onMounted(load);
</template> </template>
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'phone'"> <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>
<template v-else-if="column.key === 'date'">{{ jalali(record.createdAt) }}</template> <template v-else-if="column.key === 'date'">{{ jalali(record.createdAt) }}</template>
<template v-else-if="column.key === 'status'"> <template v-else-if="column.key === 'status'">
@@ -116,11 +157,136 @@ onMounted(load);
</a-select> </a-select>
</template> </template>
<template v-else-if="column.key === 'actions'"> <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-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
</a-popconfirm> </a-popconfirm>
</template> </template>
</template> </template>
</a-table> </a-table>
</a-card> </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> </template>
+145 -49
View File
@@ -59,6 +59,16 @@ const groups = ref<AdminCategoryDetail["groups"]>([]);
const loading = ref(false); const loading = ref(false);
const saving = 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)); const category = computed(() => categories.value.find((c) => c.id === model.value.categoryId));
/** a-select clears to undefined, the API expects null */ /** a-select clears to undefined, the API expects null */
const groupId = computed({ const groupId = computed({
@@ -161,25 +171,48 @@ onMounted(load);
<template> <template>
<PageHeader :title="id ? `ویرایش محصول: ${model.name}` : 'محصول جدید'" back="/products"> <PageHeader :title="id ? `ویرایش محصول: ${model.name}` : 'محصول جدید'" back="/products">
<a <div class="!hidden lg:!flex items-center gap-2">
v-if="id && category?.isPublished && model.isPublished" <a
:href="`${SITE_URL}/products/${model.slug}`" v-if="id && category?.isPublished && model.isPublished"
target="_blank" :href="`${SITE_URL}/products/${model.slug}`"
rel="noopener" target="_blank"
> rel="noopener"
<a-button>مشاهده در سایت</a-button> >
</a> <a-button>مشاهده در سایت</a-button>
<a-button type="primary" :loading="saving" @click="save"> </a>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template> <a-button type="primary" :loading="saving" @click="save">
ذخیره <template #icon><VuesaxIcon name="save-2" :size="16" /></template>
</a-button> ذخیره
</a-button>
</div>
</PageHeader> </PageHeader>
<a-spin :spinning="loading"> <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="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2"> <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"> <div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام محصول" required> <FormField path="name" label="نام محصول" required>
<a-input v-model:value="model.name" /> <a-input v-model:value="model.name" />
@@ -206,7 +239,8 @@ onMounted(load);
</div> </div>
</a-card> </a-card>
<a-card title="توضیحات کامل (صفحه محصول)"> <!-- Card 2: Details Description -->
<a-card title="توضیحات کامل (صفحه محصول)" :class="mobileTab !== 'desc' && 'max-lg:hidden'">
<FormField path="details" hint="برای شروع پاراگراف جدید یک خط خالی بگذارید. اگر خالی بماند، توضیح دسته نمایش داده می‌شود."> <FormField path="details" hint="برای شروع پاراگراف جدید یک خط خالی بگذارید. اگر خالی بماند، توضیح دسته نمایش داده می‌شود.">
<a-textarea <a-textarea
v-model:value="model.details" v-model:value="model.details"
@@ -216,7 +250,8 @@ onMounted(load);
</FormField> </FormField>
</a-card> </a-card>
<a-card title="ویژگی‌ها و کاربردها"> <!-- Card 3: Features & Applications -->
<a-card title="ویژگی‌ها و کاربردها" :class="mobileTab !== 'desc' && 'max-lg:hidden'">
<FormField path="features" label="ویژگی‌های کلیدی" hint="هر ویژگی در یک خط"> <FormField path="features" label="ویژگی‌های کلیدی" hint="هر ویژگی در یک خط">
<LinesInput v-model="model.features" :rows="4" placeholder="مثلاً: زیره ضد لغزش و مقاوم در برابر روغن" /> <LinesInput v-model="model.features" :rows="4" placeholder="مثلاً: زیره ضد لغزش و مقاوم در برابر روغن" />
</FormField> </FormField>
@@ -225,7 +260,8 @@ onMounted(load);
</FormField> </FormField>
</a-card> </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 class="mb-4 text-sm text-gray-500">
ردیف‌های جدول مشخصات صفحه محصول (مثلاً «جنس رویه» ← «چرم طبیعی»). کد، دسته، برند و موارد زیر به‌صورت خودکار به جدول اضافه می‌شوند. ردیف‌های جدول مشخصات صفحه محصول (مثلاً «جنس رویه» ← «چرم طبیعی»). کد، دسته، برند و موارد زیر به‌صورت خودکار به جدول اضافه می‌شوند.
</p> </p>
@@ -260,40 +296,19 @@ onMounted(load);
</div> </div>
</a-card> </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>
<div class="space-y-6"> <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"> <FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" /> <a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "پیش‌نویس (مخفی)" }}</span> <span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "پیش‌نویس (مخفی)" }}</span>
</FormField> </FormField>
</a-card> </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> <FormField path="categoryId" label="دسته" required>
<a-select <a-select
v-model:value="model.categoryId" v-model:value="model.categoryId"
@@ -312,12 +327,56 @@ onMounted(load);
/> />
</FormField> </FormField>
</a-card> </a-card>
<a-card title="تصویر اصلی">
<FormField path="imageId" hint="بدون تصویر، کاور دسته نمایش داده می‌شود"> <!-- Card: Product Images (Cover + Gallery) -->
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" /> <a-card title="تصاویر محصول" :class="mobileTab !== 'media' && 'max-lg:hidden'">
</FormField> <!-- 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>
<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="برند"> <FormField path="brand" label="برند">
<a-input v-model:value="model.brand" /> <a-input v-model:value="model.brand" />
</FormField> </FormField>
@@ -331,7 +390,9 @@ onMounted(load);
<a-input v-model:value="model.minOrder" placeholder="مثلاً: ۱ کارتن" /> <a-input v-model:value="model.minOrder" placeholder="مثلاً: ۱ کارتن" />
</FormField> </FormField>
</a-card> </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="خالی = نام محصول"> <FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام محصول">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count /> <a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField> </FormField>
@@ -341,6 +402,41 @@ onMounted(load);
</a-card> </a-card>
</div> </div>
</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-form>
</a-spin> </a-spin>
</template> </template>
+409 -19
View File
@@ -7,9 +7,11 @@ import { api, errorMessage } from "@/api/http";
import type { AdminCategory, AdminCategoryDetail, AdminProduct } from "@/api/types"; import type { AdminCategory, AdminCategoryDetail, AdminProduct } from "@/api/types";
import AdminPage from "@/components/AdminPage.vue"; import AdminPage from "@/components/AdminPage.vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue"; import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useAuthStore } from "@/stores/auth";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore();
const tableShell = ref<HTMLElement | null>(null); const tableShell = ref<HTMLElement | null>(null);
const scrollY = ref(360); const scrollY = ref(360);
let tableResizeObserver: ResizeObserver | null = null; let tableResizeObserver: ResizeObserver | null = null;
@@ -44,11 +46,62 @@ const loading = ref(false);
const categories = ref<AdminCategory[]>([]); const categories = ref<AdminCategory[]>([]);
const groups = ref<AdminCategoryDetail["groups"]>([]); const groups = ref<AdminCategoryDetail["groups"]>([]);
const filtersOpen = ref(false); const filtersOpen = ref(false);
const mobileFilterDrawer = ref(false);
const activeFilterCount = computed( const activeFilterCount = computed(
() => [filters.categoryId, filters.groupId, filters.status].filter(Boolean).length, () => [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) */ /** products without their own photo show the category cover (same fallback as the website) */
const thumbOf = (row: AdminProduct) => const thumbOf = (row: AdminProduct) =>
row.image row.image
@@ -219,8 +272,22 @@ onBeforeUnmount(() => {
<template> <template>
<AdminPage lock-body> <AdminPage lock-body>
<template #head> <template #head>
<div class="toolbar"> <!-- Desktop Toolbar (lg and up) -->
<div class="toolbar !hidden lg:!flex">
<div class="toolbar__start"> <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 <a-popover
v-model:open="filtersOpen" v-model:open="filtersOpen"
trigger="click" trigger="click"
@@ -266,19 +333,6 @@ onBeforeUnmount(() => {
</a-button> </a-button>
</a-badge> </a-badge>
</a-popover> </a-popover>
<a-input-search
v-model:value="filters.q"
allow-clear
class="toolbar__search"
placeholder="جستجوی نام یا کد"
@search="
() => {
filters.page = 1;
load();
}
"
/>
</div> </div>
<RouterLink <RouterLink
@@ -288,15 +342,87 @@ onBeforeUnmount(() => {
query: filters.categoryId ? { categoryId: filters.categoryId } : {}, query: filters.categoryId ? { categoryId: filters.categoryId } : {},
}" }"
> >
<a-button type="primary"> <a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template> <template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید محصول جدید
</a-button> </a-button>
</RouterLink> </RouterLink>
</div> </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> </template>
<div ref="tableShell" class="table-shell"> <!-- Desktop Table View (lg and up) -->
<div ref="tableShell" class="table-shell !hidden lg:!flex">
<a-table <a-table
class="table-shell__table" class="table-shell__table"
:columns="columns" :columns="columns"
@@ -366,6 +492,7 @@ onBeforeUnmount(() => {
<template #icon><VuesaxIcon name="copy" :size="14" /></template> <template #icon><VuesaxIcon name="copy" :size="14" /></template>
</a-button> </a-button>
<a-popconfirm <a-popconfirm
v-if="auth.isAdmin"
title="این محصول حذف شود؟" title="این محصول حذف شود؟"
ok-text="حذف" ok-text="حذف"
cancel-text="انصراف" cancel-text="انصراف"
@@ -380,6 +507,269 @@ onBeforeUnmount(() => {
</template> </template>
</a-table> </a-table>
</div> </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> </AdminPage>
</template> </template>
+28 -9
View File
@@ -32,24 +32,43 @@ async function save() {
</script> </script>
<template> <template>
<PageHeader title="حساب کاربری" /> <PageHeader title="حساب کاربری" subtitle="مشخصات فردی، نقش دسترسی و تغییر رمز عبور ورود به سامانه" />
<div class="grid max-w-4xl gap-6 md:grid-cols-2"> <div class="grid max-w-4xl gap-4 md:gap-6 md:grid-cols-2">
<a-card title="مشخصات"> <!-- User Identity Card -->
<a-descriptions :column="1"> <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="نام">{{ auth.user?.name }}</a-descriptions-item>
<a-descriptions-item label="موبایل"><span dir="ltr">{{ auth.user?.phone }}</span></a-descriptions-item> <a-descriptions-item label="موبایل"><span dir="ltr" class="font-mono">{{ 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="نقش دسترسی">
<a-tag :color="auth.user?.role === 'ADMIN' ? 'orange' : 'blue'">
{{ auth.user && ROLE_LABELS[auth.user.role] }}
</a-tag>
</a-descriptions-item>
</a-descriptions> </a-descriptions>
</a-card> </a-card>
<!-- Change Password Card -->
<a-card title="تغییر رمز عبور"> <a-card title="تغییر رمز عبور">
<a-form layout="vertical" @submit.prevent="save"> <a-form layout="vertical" @submit.prevent="save">
<FormField path="currentPassword" label="رمز فعلی" required> <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>
<FormField path="newPassword" label="رمز جدید" hint="حداقل ۸ کاراکتر" required> <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> </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-form>
</a-card> </a-card>
</div> </div>
+102 -4
View File
@@ -8,6 +8,7 @@ import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { SectionResponse } from "@/api/types"; import type { SectionResponse } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm"; import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
import { jalali, SITE_URL } from "@/utils/format"; import { jalali, SITE_URL } from "@/utils/format";
import AboutForm from "@/sections/AboutForm.vue"; import AboutForm from "@/sections/AboutForm.vue";
import ContactForm from "@/sections/ContactForm.vue"; import ContactForm from "@/sections/ContactForm.vue";
@@ -37,6 +38,7 @@ const FORMS: Record<SectionKey, Component> = {
footer: FooterForm, footer: FooterForm,
}; };
const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
const key = String(route.params.key); const key = String(route.params.key);
const valid = isSectionKey(key); const valid = isSectionKey(key);
@@ -55,7 +57,11 @@ const label = computed(() => (valid ? SECTION_LABELS[sectionKey] : null));
async function load() { async function load() {
try { try {
const res = await api<SectionResponse>(`/admin/sections/${sectionKey}`); 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; updatedAt.value = res.updatedAt;
loaded.value = true; loaded.value = true;
} catch (e) { } 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(); if (valid) void load();
</script> </script>
@@ -90,16 +123,81 @@ if (valid) void load();
:subtitle="updatedAt ? `آخرین ذخیره: ${jalali(updatedAt)}` : label!.desc" :subtitle="updatedAt ? `آخرین ذخیره: ${jalali(updatedAt)}` : label!.desc"
back="/sections" back="/sections"
> >
<a :href="SITE_URL" target="_blank" rel="noopener"><a-button>مشاهده سایت</a-button></a> <div v-if="sectionKey !== 'settings'" class="max-lg:hidden flex items-center gap-2 me-2">
<a-button type="primary" :loading="saving" :disabled="!loaded" @click="save"> <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> <template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره ذخیره
</a-button> </a-button>
</PageHeader> </PageHeader>
<a-spin :spinning="!loaded"> <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" /> <component :is="FORMS[sectionKey]" v-model="model" />
</a-form> </a-form>
</a-spin> </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>
</template> </template>
+144 -10
View File
@@ -1,24 +1,158 @@
<script setup lang="ts"> <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 PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { jalali } from "@/utils/format";
const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
// site-wide settings are editable by admins only // site-wide settings are editable by admins only
const keys = SECTION_KEYS.filter((k) => k !== "settings" || auth.isAdmin); 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> </script>
<template> <template>
<PageHeader <PageHeader
title="بخش‌های سایت" 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-spin :spinning="loading">
<a-card hoverable class="h-full"> <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div class="font-bold text-gray-800">{{ SECTION_LABELS[key].title }}</div> <div
<p class="mt-2 text-sm leading-6 text-gray-500">{{ SECTION_LABELS[key].desc }}</p> v-for="key in keys"
</a-card> :key="key"
</RouterLink> class="block group"
</div> >
<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> </template>
+154 -1
View File
@@ -110,7 +110,8 @@ onMounted(load);
</a-button> </a-button>
</PageHeader> </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 }"> <a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 700 }">
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'phone'"><span dir="ltr">{{ record.phone }}</span></template> <template v-if="column.key === 'phone'"><span dir="ltr">{{ record.phone }}</span></template>
@@ -139,6 +140,155 @@ onMounted(load);
</a-table> </a-table>
</a-card> </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 <a-modal
:open="editing !== null" :open="editing !== null"
:title="isNew ? 'کاربر جدید' : 'ویرایش کاربر'" :title="isNew ? 'کاربر جدید' : 'ویرایش کاربر'"
@@ -168,6 +318,9 @@ onMounted(load);
:disabled="editing === auth.user?.id" :disabled="editing === auth.user?.id"
:options="ROLE_OPTIONS" :options="ROLE_OPTIONS"
/> />
<div class="mt-1.5 text-[11px] text-gray-500">
{{ model.role === 'ADMIN' ? 'مدیر کل: دسترسی کامل سیستمی، حذف داده‌ها، تنظیمات و مدیریت کاربران' : 'ویرایشگر: ویرایش محتوا، محصولات، بخش‌ها و پیام‌ها (بدون حذف)' }}
</div>
</FormField> </FormField>
<FormField v-if="!isNew" path="isActive"> <FormField v-if="!isNew" path="isActive">
<a-checkbox v-model:checked="model.isActive" :disabled="editing === auth.user?.id">حساب فعال است</a-checkbox> <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; direction: ltr;
text-align: left; 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", "type": "module",
"scripts": { "scripts": {
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
"dev": "nest start --watch --preserveWatchOutput", "dev": "nest start --watch --preserveWatchOutput --entryFile main.js",
"start": "node dist/main.js", "start": "node dist/main.js",
"typecheck": "prisma generate && tsc --noEmit -p tsconfig.json", "typecheck": "prisma generate && tsc --noEmit -p tsconfig.json",
"test": "vitest run --config ./vitest.config.e2e.ts", "test": "vitest run --config ./vitest.config.e2e.ts",
+3 -1
View File
@@ -22,7 +22,7 @@ import {
reorderSchema, reorderSchema,
} from "@imen/shared"; } from "@imen/shared";
import { z } from "zod"; 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 { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import { MediaModule } from "../media/media.controller.js"; import { MediaModule } from "../media/media.controller.js";
import { CategoriesService } from "./categories.service.js"; import { CategoriesService } from "./categories.service.js";
@@ -68,6 +68,7 @@ export class CategoriesController {
return this.categories.update(id, body, user.id); return this.categories.update(id, body, user.id);
} }
@Roles("ADMIN")
@Delete(":id") @Delete(":id")
@HttpCode(204) @HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { 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); return this.products.update(id, body, user.id);
} }
@Roles("ADMIN")
@Delete(":id") @Delete(":id")
@HttpCode(204) @HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
+2 -1
View File
@@ -24,7 +24,7 @@ import {
} from "@imen/shared"; } from "@imen/shared";
import type { Request } from "express"; import type { Request } from "express";
import { z } from "zod"; 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 { AuditService } from "../common/common.module.js";
import { IdPipe, ZodPipe } from "../common/zod.pipe.js"; import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import type { ContactMessage } from "../generated/prisma/client.js"; import type { ContactMessage } from "../generated/prisma/client.js";
@@ -123,6 +123,7 @@ export class MessagesController {
return this.contact.setStatus(id, body.status); return this.contact.setStatus(id, body.status);
} }
@Roles("ADMIN")
@Delete(":id") @Delete(":id")
@HttpCode(204) @HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { 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 { 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 { AdminAuth } from "../common/auth.js";
import { toMessageDto } from "../contact/contact.module.js"; import { toMessageDto } from "../contact/contact.module.js";
import { PrismaService } from "../prisma/prisma.service.js"; import { PrismaService } from "../prisma/prisma.service.js";
const DAY_NAMES = ["یکشنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه"];
@AdminAuth() @AdminAuth()
@Controller("admin/dashboard") @Controller("admin/dashboard")
export class DashboardController { export class DashboardController {
@@ -11,20 +13,61 @@ export class DashboardController {
@Get() @Get()
async get(): Promise<DashboardDto> { async get(): Promise<DashboardDto> {
const [categories, products, draftProducts, newMessages, media, messages, activity] = const [
await Promise.all([ categories,
this.prisma.category.count(), products,
this.prisma.product.count(), draftProducts,
this.prisma.product.count({ where: { isPublished: false } }), newMessages,
this.prisma.contactMessage.count({ where: { status: "NEW" } }), readMessages,
this.prisma.media.count(), archivedMessages,
this.prisma.contactMessage.findMany({ orderBy: { id: "desc" }, take: 5 }), media,
this.prisma.auditLog.findMany({ messages,
orderBy: { id: "desc" }, activity,
take: 10, categoryRows,
include: { user: { select: { name: true } } }, 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 { return {
categories, categories,
@@ -41,6 +84,23 @@ export class DashboardController {
user: a.user?.name ?? null, user: a.user?.name ?? null,
createdAt: a.createdAt.toISOString(), 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 { FileInterceptor } from "@nestjs/platform-express";
import { mediaUpdateSchema } from "@imen/shared"; import { mediaUpdateSchema } from "@imen/shared";
import { z } from "zod"; 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 { IdPipe, ZodPipe } from "../common/zod.pipe.js";
import { MediaService } from "./media.service.js"; import { MediaService } from "./media.service.js";
@@ -54,6 +54,7 @@ export class MediaController {
return this.media.updateAlt(id, body.alt, user.id); return this.media.updateAlt(id, body.alt, user.id);
} }
@Roles("ADMIN")
@Delete(":id") @Delete(":id")
@HttpCode(204) @HttpCode(204)
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
+19 -15
View File
@@ -50,22 +50,26 @@ export class S3Storage {
} catch { } catch {
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })); await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
} }
await this.client.send( try {
new PutBucketPolicyCommand({ await this.client.send(
Bucket: this.bucket, new PutBucketPolicyCommand({
Policy: JSON.stringify({ Bucket: this.bucket,
Version: "2012-10-17", Policy: JSON.stringify({
Statement: [ Version: "2012-10-17",
{ Statement: [
Effect: "Allow", {
Principal: { AWS: ["*"] }, Effect: "Allow",
Action: ["s3:GetObject"], Principal: { AWS: ["*"] },
Resource: [`arn:aws:s3:::${this.bucket}/*`], 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, Module,
NotFoundException, NotFoundException,
Param, Param,
Patch,
Put, Put,
ServiceUnavailableException, ServiceUnavailableException,
} from "@nestjs/common"; } from "@nestjs/common";
@@ -75,6 +76,68 @@ export class SectionsService {
this.revalidate.trigger(); this.revalidate.trigger();
return { key, data: parsed.data, updatedAt: row.updatedAt }; 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() @AdminAuth()
@@ -82,6 +145,11 @@ export class SectionsService {
export class SectionsController { export class SectionsController {
constructor(private readonly sections: SectionsService) {} constructor(private readonly sections: SectionsService) {}
@Get()
list() {
return this.sections.listAdmin();
}
@Get(":key") @Get(":key")
get(@Param("key") key: string) { get(@Param("key") key: string) {
return this.sections.get(key); return this.sections.get(key);
@@ -91,6 +159,17 @@ export class SectionsController {
update(@Param("key") key: string, @Body() body: unknown, @CurrentUser() user: AuthUser) { update(@Param("key") key: string, @Body() body: unknown, @CurrentUser() user: AuthUser) {
return this.sections.update(key, body, user); 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({ @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: { hero: {
enabled: true,
badge: "تأمین‌کننده رسمی تجهیزات حفاظت فردی (PPE)", badge: "تأمین‌کننده رسمی تجهیزات حفاظت فردی (PPE)",
title: "ایمنی یعنی", title: "ایمنی یعنی",
titleHighlight: "بازگشت به خانه", titleHighlight: "بازگشت به خانه",
@@ -62,6 +63,7 @@ export const SECTIONS: SiteSections = {
}, },
tape: { tape: {
enabled: true,
items: [ items: [
"ISO 9001:2015", "ISO 9001:2015",
"CE CERTIFIED", "CE CERTIFIED",
@@ -75,6 +77,7 @@ export const SECTIONS: SiteSections = {
}, },
stats: { stats: {
enabled: true,
items: [ items: [
{ end: 18, suffix: "+", label: "سال تجربه در صنعت ایمنی", note: "SINCE 2007" }, { end: 18, suffix: "+", label: "سال تجربه در صنعت ایمنی", note: "SINCE 2007" },
{ end: 2400, suffix: "+", label: "کد کالای ایمنی", note: "PPE CATALOG" }, { end: 2400, suffix: "+", label: "کد کالای ایمنی", note: "PPE CATALOG" },
@@ -84,6 +87,7 @@ export const SECTIONS: SiteSections = {
}, },
productsIntro: { productsIntro: {
enabled: true,
head: { head: {
tag: "۰۱ — محصولات", tag: "۰۱ — محصولات",
title: "خط اول دفاع،\nتجهیزات ماست", title: "خط اول دفاع،\nتجهیزات ماست",
@@ -111,6 +115,7 @@ export const SECTIONS: SiteSections = {
}, },
about: { about: {
enabled: true,
head: { head: {
tag: "۰۲ — درباره ما", tag: "۰۲ — درباره ما",
title: "ایمنی برای ما شعار نیست؛", title: "ایمنی برای ما شعار نیست؛",
@@ -134,6 +139,7 @@ export const SECTIONS: SiteSections = {
}, },
services: { services: {
enabled: true,
head: { head: {
tag: "۰۳ — خدمات", tag: "۰۳ — خدمات",
title: "فراتر از تأمین؛\nهمراهی کامل ایمنی", title: "فراتر از تأمین؛\nهمراهی کامل ایمنی",
@@ -185,6 +191,7 @@ export const SECTIONS: SiteSections = {
}, },
standards: { standards: {
enabled: true,
head: { head: {
tag: "۰۴ — استانداردها", tag: "۰۴ — استانداردها",
title: "مهرتأیید جهانی\nروی هر محصول", title: "مهرتأیید جهانی\nروی هر محصول",
@@ -204,6 +211,7 @@ export const SECTIONS: SiteSections = {
}, },
industries: { industries: {
enabled: true,
head: { head: {
tag: "۰۵ — صنایع", tag: "۰۵ — صنایع",
title: "حضور در سخت‌ترین\nمیدان‌های کاری", title: "حضور در سخت‌ترین\nمیدان‌های کاری",
@@ -221,6 +229,7 @@ export const SECTIONS: SiteSections = {
}, },
testimonials: { testimonials: {
enabled: true,
head: { tag: "۰۶ — اعتماد", title: "آنچه مشتریان ما می‌گویند", highlight: "", desc: "" }, head: { tag: "۰۶ — اعتماد", title: "آنچه مشتریان ما می‌گویند", highlight: "", desc: "" },
items: [ items: [
{ {
@@ -242,6 +251,7 @@ export const SECTIONS: SiteSections = {
}, },
contact: { contact: {
enabled: true,
head: { head: {
tag: "۰۷ — تماس", tag: "۰۷ — تماس",
title: "بیایید ایمنی را\nبا هم جدی بگیریم", title: "بیایید ایمنی را\nبا هم جدی بگیریم",
@@ -258,6 +268,7 @@ export const SECTIONS: SiteSections = {
}, },
footer: { footer: {
enabled: true,
about: about:
"تأمین‌کننده تخصصی تجهیزات حفاظت فردی و راهکارهای ایمنی صنعتی؛ همراه سازمان‌ها از سال ۱۳۸۶ در مسیر صفر شدن حوادث کاری.", "تأمین‌کننده تخصصی تجهیزات حفاظت فردی و راهکارهای ایمنی صنعتی؛ همراه سازمان‌ها از سال ۱۳۸۶ در مسیر صفر شدن حوادث کاری.",
copyright: "© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.", copyright: "© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.",
+95 -3
View File
@@ -7,7 +7,7 @@
* SEED_OVERWRITE_SECTIONS=1 ... (reset website sections to the initial content) * SEED_OVERWRITE_SECTIONS=1 ... (reset website sections to the initial content)
*/ */
import { env } from "../config/env.js"; 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 path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { normalizeIrMobile, SECTION_KEYS, sectionSchemas } from "@imen/shared"; 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 } }); let media = await prisma.media.findUnique({ where: { key } });
if (!media) { if (!media) {
const image = await toWebp(await readFile(path.join(ASSETS_DIR, rel))); 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({ media = await prisma.media.create({
data: { data: {
key, key,
@@ -170,14 +178,98 @@ async function seedAdmin() {
console.log(` admin user ${phone}`); 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() { 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…"); console.log("seeding sections…");
await seedSections(); await seedSections();
console.log("seeding catalog…"); console.log("seeding catalog…");
await seedCatalog(); await seedCatalog();
console.log("seeding admin…"); console.log("seeding admin…");
await seedAdmin(); await seedAdmin();
console.log("seeding messages…");
await seedMessages();
const [categories, products, media] = await Promise.all([ const [categories, products, media] = await Promise.all([
prisma.category.count(), prisma.category.count(),
prisma.product.count(), prisma.product.count(),
+29
View File
@@ -211,3 +211,32 @@ body {
#0b1224 10px 20px #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"> <script setup lang="ts">
import { AnimatePresence, motion } from "motion-v"; import { AnimatePresence, motion } from "motion-v";
import { ArrowUp } from "lucide-vue-next"; import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const show = useScrolledPast(700); const show = useScrolledPast(700);
</script> </script>
@@ -16,7 +16,7 @@ const show = useScrolledPast(700);
:exit="{ opacity: 0, y: 24 }" :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" 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> </motion.a>
</AnimatePresence> </AnimatePresence>
</template> </template>
+29 -32
View File
@@ -1,45 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SocialType } from "@imen/shared"; import type { SocialType } from "@imen/shared";
import { import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
AtSign,
Globe,
Instagram,
Linkedin,
MessageCircle,
Phone,
Send,
Youtube,
} from "lucide-vue-next";
import type { Component } from "vue";
import Logo from "~/components/ui/Logo.vue"; import Logo from "~/components/ui/Logo.vue";
const QUICK_LINKS = [ const ALL_QUICK_LINKS = [
{ href: "/#home", label: "خانه" }, { href: "/#home", label: "خانه", section: "hero" },
{ href: "/#products", label: "محصولات" }, { href: "/#products", label: "محصولات", section: "productsIntro" },
{ href: "/#about", label: "درباره ما" }, { href: "/#about", label: "درباره ما", section: "about" },
{ href: "/#services", label: "خدمات" }, { href: "/#services", label: "خدمات", section: "services" },
{ href: "/#standards", label: "استانداردها" }, { href: "/#standards", label: "استانداردها", section: "standards" },
{ href: "/#industries", label: "صنایع" }, { href: "/#industries", label: "صنایع", section: "industries" },
]; ] as const;
const SOCIAL_META: Record<SocialType, { icon: Component; label: string }> = { const SOCIAL_META: Record<SocialType, { icon: string; label: string }> = {
website: { icon: Globe, label: "وب‌سایت" }, website: { icon: "global", label: "وب‌سایت" },
telegram: { icon: Send, label: "تلگرام" }, telegram: { icon: "send-2", label: "تلگرام" },
instagram: { icon: Instagram, label: "اینستاگرام" }, instagram: { icon: "instagram", label: "اینستاگرام" },
linkedin: { icon: Linkedin, label: "لینکدین" }, linkedin: { icon: "link", label: "لینکدین" },
whatsapp: { icon: MessageCircle, label: "واتساپ" }, whatsapp: { icon: "messages-2", label: "واتساپ" },
bale: { icon: MessageCircle, label: "بله" }, bale: { icon: "messages-2", label: "بله" },
eitaa: { icon: MessageCircle, label: "ایتا" }, eitaa: { icon: "messages-2", label: "ایتا" },
aparat: { icon: Youtube, label: "آپارات" }, aparat: { icon: "video-play", label: "آپارات" },
youtube: { icon: Youtube, label: "یوتیوب" }, youtube: { icon: "video-play", label: "یوتیوب" },
email: { icon: AtSign, label: "ایمیل" }, email: { icon: "sms", label: "ایمیل" },
phone: { icon: Phone, label: "تماس" }, phone: { icon: "call", label: "تماس" },
}; };
const site = useSiteStore(); const site = useSiteStore();
const footer = computed(() => site.sections.footer); const footer = computed(() => site.sections.footer);
const settings = computed(() => site.settings); 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(() => const PRODUCT_LINKS = computed(() =>
site.categories.map((c) => ({ label: c.name, href: `/categories/${c.slug}` })), site.categories.map((c) => ({ label: c.name, href: `/categories/${c.slug}` })),
); );
@@ -75,7 +72,7 @@ const SOCIALS = computed(() =>
:rel="s.external ? 'noopener' : undefined" :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" 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> </a>
</div> </div>
</div> </div>
+25 -15
View File
@@ -1,20 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { AnimatePresence, motion } from "motion-v"; 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"; import Logo from "~/components/ui/Logo.vue";
const LINKS = [ const ALL_LINKS = [
{ href: "/#home", label: "خانه" }, { href: "/#home", label: "خانه", section: "hero" },
{ href: "/#products", label: "محصولات" }, { href: "/#products", label: "محصولات", section: "productsIntro" },
{ href: "/#about", label: "درباره ما" }, { href: "/#about", label: "درباره ما", section: "about" },
{ href: "/#services", label: "خدمات" }, { href: "/#services", label: "خدمات", section: "services" },
{ href: "/#standards", label: "استانداردها" }, { href: "/#standards", label: "استانداردها", section: "standards" },
{ href: "/#industries", label: "صنایع" }, { href: "/#industries", label: "صنایع", section: "industries" },
{ href: "/#contact", label: "تماس" }, { href: "/#contact", label: "تماس", section: "contact" },
]; ] as const;
const ui = useUiStore(); 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 scrolled = useScrolledPast(40);
const { time, shift } = useTehranClock(); const { time, shift } = useTehranClock();
@@ -69,7 +79,7 @@ onBeforeUnmount(() => {
href="/#contact" 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" 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> </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" 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" @click="ui.openMenu"
> >
<Menu class="size-5" /> <VuesaxIcon name="menu" class="size-5" />
</button> </button>
</div> </div>
</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" class="flex size-11 items-center justify-center rounded-lg border border-line transition-colors hover:border-accent hover:text-accent"
@click="ui.closeMenu" @click="ui.closeMenu"
> >
<X class="size-5" /> <VuesaxIcon name="close-circle" class="size-5" />
</button> </button>
</div> </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 justify-between border-t border-line p-8 text-sm">
<div class="flex items-center gap-3 text-muted"> <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> <a :href="`tel:${settings.phone}`" class="font-mono" dir="ltr">{{ settings.phoneDisplay }}</a>
</div> </div>
<div class="text-xs text-muted">تهران — شیفت {{ shift }}، {{ time }}</div> <div class="text-xs text-muted">تهران — شیفت {{ shift }}، {{ time }}</div>
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { motion } from "motion-v"; import { motion } from "motion-v";
import { ShieldCheck } from "lucide-vue-next"; import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const STEPS = [ const STEPS = [
"بررسی سیستم‌های ایمنی", "بررسی سیستم‌های ایمنی",
@@ -51,7 +51,7 @@ const step = computed(
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span class="flex size-12 items-center justify-center rounded-xl bg-accent text-bg"> <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>
<span class="font-mono text-xs tracking-[0.35em] text-muted"> <span class="font-mono text-xs tracking-[0.35em] text-muted">
IMEN PISHGAMAN CO. IMEN PISHGAMAN CO.
+10 -19
View File
@@ -1,16 +1,7 @@
<script setup lang="ts"> <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 { 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 type { Rule } from "ant-design-vue/es/form";
import { import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
CheckCircle2,
Clock,
Loader2,
Mail,
MapPin,
Phone,
Send,
Siren,
} from "lucide-vue-next";
import Lines from "~/components/ui/Lines.vue"; import Lines from "~/components/ui/Lines.vue";
import Reveal from "~/components/ui/Reveal.vue"; import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue"; import SectionHead from "~/components/ui/SectionHead.vue";
@@ -21,10 +12,10 @@ const content = computed(() => site.sections.contact);
const INFO = computed(() => { const INFO = computed(() => {
const s = site.settings; const s = site.settings;
return [ return [
{ icon: MapPin, label: "نشانی", value: `${s.address.locality}، ${s.address.street}` }, { icon: "location", label: "نشانی", value: `${s.address.locality}، ${s.address.street}` },
{ icon: Phone, label: "تلفن تماس", value: s.phoneDisplay, ltr: true, href: `tel:${s.phone}` }, { icon: "call", label: "تلفن تماس", value: s.phoneDisplay, ltr: true, href: `tel:${s.phone}` },
{ icon: Mail, label: "ایمیل", value: s.email, ltr: true, href: `mailto:${s.email}` }, { icon: "sms", label: "ایمیل", value: s.email, ltr: true, href: `mailto:${s.email}` },
{ icon: Clock, label: "ساعات کاری", value: s.workingHours }, { icon: "clock", label: "ساعات کاری", value: s.workingHours },
]; ];
}); });
@@ -61,7 +52,7 @@ const contact = useContactStore();
<span <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" 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> </span>
<div class="min-w-0"> <div class="min-w-0">
<div class="text-xs text-muted">{{ item.label }}</div> <div class="text-xs text-muted">{{ item.label }}</div>
@@ -80,7 +71,7 @@ const contact = useContactStore();
<Reveal :delay="0.45"> <Reveal :delay="0.45">
<div class="mt-8 flex items-start gap-4 rounded-xl border border-hazard/30 bg-hazard/5 p-5"> <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>
<div class="font-bold text-hazard">{{ content.emergencyTitle }}</div> <div class="font-bold text-hazard">{{ content.emergencyTitle }}</div>
<p class="mt-1.5 text-sm leading-7 text-muted"> <p class="mt-1.5 text-sm leading-7 text-muted">
@@ -105,7 +96,7 @@ const contact = useContactStore();
v-if="contact.status === 'success'" 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" 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> <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"> <p class="mt-2.5 max-w-sm text-sm leading-7 text-muted">
{{ content.successText }} {{ content.successText }}
@@ -176,11 +167,11 @@ const contact = useContactStore();
:disabled="contact.status === 'loading'" :disabled="contact.status === 'loading'"
> >
<template v-if="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>
<template v-else> <template v-else>
<Send class="size-5" /> <VuesaxIcon name="send-2" class="size-5" />
ارسال درخواست مشاوره ارسال درخواست مشاوره
</template> </template>
</AButton> </AButton>
+39 -83
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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"; import DynamicIcon from "~/components/ui/DynamicIcon.vue";
const hero = computed(() => useSiteStore().sections.hero); const hero = computed(() => useSiteStore().sections.hero);
@@ -16,68 +16,81 @@ const hero = computed(() => useSiteStore().sections.hero);
height="1008" height="1008"
fetchpriority="high" fetchpriority="high"
decoding="async" 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" /> <!-- Mobile Gradient: 0 at the top so image is fully clear, reaches 100% at the texts -->
<div class="absolute inset-0 bg-gradient-to-t from-bg via-transparent to-bg/60" /> <div
<div class="absolute inset-0 bg-grid opacity-50" /> 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> </div>
<!-- ---- side tape ---- --> <!-- ---- 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 ---- --> <!-- ---- content ---- -->
<div <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="w-full max-w-3xl">
<div class="lg:col-span-7"> <div>
<!-- Badge -->
<div <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="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 }} {{ hero.badge }}
</span> </span>
</div> </div>
<!-- Title & Highlight -->
<h1 <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 }} {{ hero.title }}
<br /> <br />
<span <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 }} {{ hero.titleHighlight }}
</span> </span>
</h1> </h1>
<!-- Intro text -->
<p <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 }} {{ hero.intro }}
</p> </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 <a
:href="hero.primaryCta.href" :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 }} {{ hero.primaryCta.label }}
<ArrowLeft <VuesaxIcon
class="size-5 transition-transform duration-300 group-hover:-translate-x-1.5" name="arrow-left"
class="size-3.5 sm:size-5 transition-transform duration-300 group-hover:-translate-x-1.5"
/> />
</a> </a>
<a <a
:href="hero.secondaryCta.href" :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 }} {{ hero.secondaryCta.label }}
</a> </a>
</div> </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 <span
v-for="c in hero.chips" v-for="c in hero.chips"
:key="c.label" :key="c.label"
@@ -88,84 +101,27 @@ const hero = computed(() => useSiteStore().sections.hero);
</span> </span>
</div> </div>
</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>
</div> </div>
<!-- ---- scroll hint ---- --> <!-- ---- 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"> <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> <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>
</div> </div>
<!-- ---- bottom bar ---- --> <!-- ---- bottom bar ---- -->
<div class="animate-fade-up-8 relative z-10 border-t border-ink/10 backdrop-blur-sm"> <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 <div
v-for="s in hero.miniStats" v-for="s in hero.miniStats"
:key="s.label" :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-base font-black text-ink sm:text-2xl md:text-3xl">{{ s.value }}</span>
<span class="text-xs leading-5 text-muted md:text-sm">{{ s.label }}</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> </div>
</div> </div>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { AnimatePresence, motion, useMotionValue, useSpring } from "motion-v"; 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"; import SectionHead from "~/components/ui/SectionHead.vue";
const industries = computed(() => useSiteStore().sections.industries); const industries = computed(() => useSiteStore().sections.industries);
@@ -59,7 +59,7 @@ const onMove = (e: MouseEvent) => {
<span <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" 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> </span>
</div> </div>
</motion.div> </motion.div>
+149 -37
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowLeft } from "lucide-vue-next";
import DynamicIcon from "~/components/ui/DynamicIcon.vue"; import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue"; import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue"; import SectionHead from "~/components/ui/SectionHead.vue";
@@ -8,11 +7,65 @@ const site = useSiteStore();
const intro = computed(() => site.sections.productsIntro); const intro = computed(() => site.sections.productsIntro);
const categories = computed(() => site.categories); const categories = computed(() => site.categories);
// Desktop active accordion index
const active = ref(0); 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> </script>
<template> <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="mx-auto max-w-[1480px] px-5 md:px-8">
<div class="flex flex-wrap items-end justify-between gap-6"> <div class="flex flex-wrap items-end justify-between gap-6">
<SectionHead heading-id="products-title" v-bind="intro.head" /> <SectionHead heading-id="products-title" v-bind="intro.head" />
@@ -28,38 +81,96 @@ const active = ref(0);
</div> </div>
</div> </div>
<!-- ---- mobile: card grid ---- --> <!-- ---- mobile: compact interactive deck ---- -->
<div class="mx-auto mt-14 max-w-[1480px] px-5 md:hidden"> <div class="mx-auto mt-4 max-w-[1480px] px-4 md:hidden">
<div class="grid grid-cols-2 gap-3"> <!-- Snapping Cards Deck (compact size so more of side cards are visible) -->
<Reveal v-for="(cat, i) in categories" :key="cat.code" :delay="i * 0.05"> <div
<NuxtLink ref="scrollContainer"
:to="`/categories/${cat.slug}`" class="flex items-center gap-4 overflow-x-auto snap-x snap-mandatory no-scrollbar -mx-4 px-8 py-8 scroll-smooth"
class="group relative block aspect-[3/4] overflow-hidden rounded-xl border border-line transition-colors duration-300 active:border-accent/60" @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 {{ String(i + 1).padStart(2, "0") }}
v-if="cat.coverImage" </div>
: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" />
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-3"> <!-- Top Row Badges -->
<span class="font-mono text-[9px] tracking-widest text-ink/50">{{ cat.code }}</span> <div class="absolute inset-x-0 top-0 flex items-center justify-between p-2.5 z-10">
<span class="font-mono text-[10px] text-accent"> <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">
{{ faNum(String(i + 1).padStart(2, "0")) }} <span class="size-1 rounded-full bg-accent animate-pulse" />
</span> {{ cat.code }}
</div> </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"> <!-- Bottom Content Area -->
<h3 class="text-sm font-black leading-6">{{ cat.name }}</h3> <div class="absolute inset-x-0 bottom-0 p-3 z-10">
<p class="mt-0.5 font-mono text-[8px] tracking-[0.15em] text-muted">{{ cat.nameEn }}</p> <span class="font-mono text-[8.5px] tracking-[0.15em] text-hazard uppercase">
</div> {{ cat.nameEn }}
</NuxtLink> </span>
</Reveal> <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>
</div> </div>
@@ -96,7 +207,7 @@ const active = ref(0);
:class="`absolute inset-0 transition-opacity duration-700 ${ :class="`absolute inset-0 transition-opacity duration-700 ${
active === i active === i
? 'bg-gradient-to-t from-bg via-bg/35 to-bg/5' ? '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>
</div> </div>
<!-- ---- trust section ---- -->
<div <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" 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"> <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 <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> </span>
<div> <div>
<div class="font-bold">{{ t.title }}</div> <div class="font-bold text-sm sm:text-base">{{ t.title }}</div>
<div class="mt-1 text-xs leading-6 text-muted">{{ t.desc }}</div> <div class="mt-1 text-xs leading-5 sm:leading-6 text-muted">{{ t.desc }}</div>
</div> </div>
</div> </div>
</Reveal> </Reveal>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { AnimatePresence, motion } from "motion-v"; 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 DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue"; import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.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' : 'border-line text-muted group-hover:border-accent/50 group-hover:text-accent'
}`" }`"
> >
<Plus class="size-5" /> <VuesaxIcon name="add" class="size-5" />
</span> </span>
</button> </button>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.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" /> <SectionHead heading-id="standards-title" v-bind="standards.head" />
<Reveal v-if="standards.note" :delay="0.25"> <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"> <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"> <p class="text-sm leading-8 text-ink/80">
{{ standards.note }} {{ standards.note }}
</p> </p>
@@ -37,7 +37,7 @@ const standards = computed(() => useSiteStore().sections.standards);
href="#contact" 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" 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 }} {{ standards.ctaLabel }}
</a> </a>
</Reveal> </Reveal>
+4 -4
View File
@@ -11,18 +11,18 @@ const stats = computed(() => useSiteStore().sections.stats.items);
<div <div
v-for="(s, i) in stats" v-for="(s, i) in stats"
:key="s.label" :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 <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 }} {{ s.note }}
</span> </span>
<Reveal :delay="i * 0.08"> <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" /> <CountUp :end="s.end" :suffix="s.suffix" />
</div> </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> </Reveal>
<span <span
class="absolute bottom-0 start-0 h-0.5 w-0 bg-accent transition-all duration-500 group-hover:w-full" 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"> <script setup lang="ts">
import { ShieldCheck } from "lucide-vue-next"; import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const items = computed(() => useSiteStore().sections.tape.items); const items = computed(() => useSiteStore().sections.tape.items);
</script> </script>
<template> <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="-rotate-[1.6deg] scale-[1.04]">
<div class="hazard h-2.5" /> <div class="hazard h-2 sm:h-2.5" />
<div dir="ltr" class="overflow-hidden bg-hazard py-3.5 text-bg"> <div dir="ltr" class="overflow-hidden bg-hazard py-2.5 sm:py-3.5 text-bg">
<div class="animate-marquee flex w-max"> <div class="animate-marquee flex w-max">
<span <span
v-for="(item, i) in [...items, ...items]" v-for="(item, i) in [...items, ...items]"
:key="i" :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 }} {{ 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> </span>
</div> </div>
</div> </div>
<div class="hazard h-2.5" /> <div class="hazard h-2 sm:h-2.5" />
</div> </div>
</section> </section>
</template> </template>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { AnimatePresence, motion } from "motion-v"; 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"; import SectionHead from "~/components/ui/SectionHead.vue";
const testimonials = computed(() => useSiteStore().sections.testimonials); const testimonials = computed(() => useSiteStore().sections.testimonials);
@@ -36,7 +36,7 @@ onBeforeUnmount(() => clearInterval(t));
<span <span
class="flex size-16 items-center justify-center rounded-full border border-accent/30 bg-accent/10" 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> </span>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
+3 -106
View File
@@ -1,112 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IconName } from "@imen/shared"; import VuesaxIcon from "./VuesaxIcon.vue";
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";
/** Explicit registry (matches ICON_NAMES in @imen/shared) so only these icons are bundled */ defineProps<{ name: string; size?: number | string }>();
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 }>();
</script> </script>
<template> <template>
<component :is="ICONS[name as IconName] ?? ShieldCheck" /> <VuesaxIcon :name="name" :size="size" />
</template> </template>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ImageOff } from "lucide-vue-next"; import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
withDefaults( withDefaults(
defineProps<{ defineProps<{
@@ -17,7 +17,7 @@ withDefaults(
<div <div
:class="`flex flex-col items-center justify-center border border-dashed border-line bg-panel ${aspectClass}`" :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> <span class="mt-2 font-mono text-[10px] tracking-wider text-muted/40">{{ label }}</span>
</div> </div>
</template> </template>
+2 -2
View File
@@ -34,7 +34,7 @@ withDefaults(
<component <component
:is="`h${level}`" :is="`h${level}`"
:id="headingId" :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"> <slot name="title">
<Lines v-if="title" :text="title" /> <Lines v-if="title" :text="title" />
@@ -46,7 +46,7 @@ withDefaults(
</component> </component>
</Reveal> </Reveal>
<Reveal v-if="desc" :delay="0.16"> <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 }} {{ desc }}
</p> </p>
</Reveal> </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"> <script setup lang="ts">
import type { NuxtError } from "#app"; import type { NuxtError } from "#app";
import { ShieldAlert } from "lucide-vue-next"; import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const props = defineProps<{ error: NuxtError }>(); const props = defineProps<{ error: NuxtError }>();
const notFound = computed(() => props.error.statusCode === 404); const notFound = computed(() => props.error.statusCode === 404);
@@ -15,7 +15,7 @@ const goHome = () => clearError({ redirect: "/" });
<template> <template>
<main class="flex min-h-screen flex-col items-center justify-center px-5 text-center"> <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" /> <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"> <h1 class="mt-8 text-4xl font-black md:text-6xl">
{{ notFound ? "صفحه مورد نظر پیدا نشد" : "خطایی رخ داد" }} {{ notFound ? "صفحه مورد نظر پیدا نشد" : "خطایی رخ داد" }}
</h1> </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. * page that is being torn down, which crashed Vue's transition code.
*/ */
const ui = useUiStore(); const ui = useUiStore();
const site = useSiteStore();
</script> </script>
<template> <template>
@@ -27,7 +28,7 @@ const ui = useUiStore();
<slot /> <slot />
<Footer /> <Footer v-if="site.sections?.footer?.enabled !== false" />
<BackToTop /> <BackToTop />
</div> </div>
</template> </template>
+4 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicCategory } from "@imen/shared"; 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 { categoryPageSchema } from "#shared/seo/schema";
import SectionHead from "~/components/ui/SectionHead.vue"; import SectionHead from "~/components/ui/SectionHead.vue";
import Reveal from "~/components/ui/Reveal.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"> <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> </span>
</div> </div>
</NuxtLink> </NuxtLink>
@@ -231,14 +231,14 @@ const groupCount = (g: string) => category.products.filter((p) => p.group === g)
to="/#products" to="/#products"
class="group inline-flex items-center gap-2 text-muted transition-colors hover:text-accent" 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> </NuxtLink>
<a <a
href="/#contact" 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)]" 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> </a>
</div> </div>
+20 -12
View File
@@ -11,31 +11,39 @@ import Testimonials from "~/components/sections/Testimonials.vue";
import Contact from "~/components/sections/Contact.vue"; import Contact from "~/components/sections/Contact.vue";
const canonical = useAbsUrl()("/"); const canonical = useAbsUrl()("/");
const { hero } = useSiteStore().sections; const siteStore = useSiteStore();
const sections = computed(() => siteStore.sections);
const hero = computed(() => sections.value.hero);
useHead({ useHead({
link: [ link: [
{ rel: "canonical", href: canonical }, { 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({ useSeoMeta({
robots: "index, follow", robots: "index, follow",
ogUrl: canonical, ogUrl: canonical,
}); });
onMounted(() => {
void siteStore.load();
});
</script> </script>
<template> <template>
<main> <main>
<Hero /> <Hero v-if="sections.hero?.enabled !== false" />
<TapeMarquee /> <TapeMarquee v-if="sections.tape?.enabled !== false" />
<Stats /> <Stats v-if="sections.stats?.enabled !== false" />
<Products /> <Products v-if="sections.productsIntro?.enabled !== false" />
<About /> <About v-if="sections.about?.enabled !== false" />
<Services /> <Services v-if="sections.services?.enabled !== false" />
<Standards /> <Standards v-if="sections.standards?.enabled !== false" />
<Industries /> <Industries v-if="sections.industries?.enabled !== false" />
<Testimonials /> <Testimonials v-if="sections.testimonials?.enabled !== false" />
<Contact /> <Contact v-if="sections.contact?.enabled !== false" />
</main> </main>
</template> </template>
+15 -25
View File
@@ -1,17 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PublicProductDetail, PublicProductImage } from "@imen/shared"; import type { PublicProductDetail, PublicProductImage } from "@imen/shared";
import { useEventListener } from "@vueuse/core"; import { useEventListener } from "@vueuse/core";
import { import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
ArrowLeft,
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
Expand,
Headset,
Phone,
X,
} from "lucide-vue-next";
import { breadcrumbSchema, productSchema } from "#shared/seo/schema"; import { breadcrumbSchema, productSchema } from "#shared/seo/schema";
import DynamicIcon from "~/components/ui/DynamicIcon.vue"; import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.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" 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" @click="lightbox = true"
> >
<Expand class="size-4" /> <VuesaxIcon name="maximize-4" class="size-4" />
</button> </button>
<template v-if="images.length > 1"> <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" 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)" @click="go(-1)"
> >
<ChevronRight class="size-5" /> <VuesaxIcon name="arrow-right-1" class="size-5" />
</button> </button>
<button <button
type="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" 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)" @click="go(1)"
> >
<ChevronLeft class="size-5" /> <VuesaxIcon name="arrow-left-1" class="size-5" />
</button> </button>
</template> </template>
</div> </div>
@@ -358,13 +348,13 @@ useJsonLd(
@click="requestQuote" @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> </NuxtLink>
<a <a
:href="`tel:${settings.phone}`" :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" 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> <span class="font-mono tracking-wider" dir="ltr">{{ settings.phoneDisplay }}</span>
</a> </a>
</div> </div>
@@ -412,7 +402,7 @@ useJsonLd(
<Reveal v-for="(f, i) in product.features" :key="f" :delay="i * 0.04"> <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"> <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"> <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>
<span class="text-sm leading-7 text-ink/85">{{ f }}</span> <span class="text-sm leading-7 text-ink/85">{{ f }}</span>
</li> </li>
@@ -471,7 +461,7 @@ useJsonLd(
<Reveal :delay="0.18"> <Reveal :delay="0.18">
<div class="flex items-start gap-4 rounded-xl border border-hazard/30 bg-hazard/5 p-5"> <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>
<div class="font-bold text-hazard">مشاوره رایگان انتخاب تجهیزات</div> <div class="font-bold text-hazard">مشاوره رایگان انتخاب تجهیزات</div>
<p class="mt-1.5 text-sm leading-7 text-muted"> <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" 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> </NuxtLink>
</Reveal> </Reveal>
</div> </div>
@@ -537,7 +527,7 @@ useJsonLd(
<p class="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted">{{ r.nameEn }}</p> <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"> <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> </span>
</div> </div>
</NuxtLink> </NuxtLink>
@@ -553,7 +543,7 @@ useJsonLd(
:to="categoryPath" :to="categoryPath"
class="group inline-flex items-center gap-2 text-muted transition-colors hover:text-accent" 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 }} بازگشت به {{ product.category.name }}
</NuxtLink> </NuxtLink>
<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)]" 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" @click="requestQuote"
> >
<Phone class="size-4" /> <VuesaxIcon name="call" class="size-4" />
درخواست قیمت این محصول درخواست قیمت این محصول
</NuxtLink> </NuxtLink>
</div> </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" class="flex size-11 items-center justify-center rounded-lg border border-line transition-colors hover:border-accent hover:text-accent"
@click="lightbox = false" @click="lightbox = false"
> >
<X class="size-5" /> <VuesaxIcon name="close-circle" class="size-5" />
</button> </button>
</div> </div>
<div class="relative flex min-h-0 flex-1 items-center justify-center px-5 pb-8" @click.self="lightbox = false"> <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" 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)" @click="go(-1)"
> >
<ChevronRight class="size-5" /> <VuesaxIcon name="arrow-right-1" class="size-5" />
</button> </button>
<button <button
type="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" 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)" @click="go(1)"
> >
<ChevronLeft class="size-5" /> <VuesaxIcon name="arrow-left-1" class="size-5" />
</button> </button>
</template> </template>
</div> </div>
+5 -1
View File
@@ -72,9 +72,13 @@ export default defineNuxtConfig({
}, },
}, },
build: {
transpile: ["ant-design-vue", "@ant-design/icons-vue", "@ctrl/tinycolor"],
},
vite: { vite: {
plugins: [tailwindcss()], 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 }, typescript: { strict: true },
+2 -2
View File
@@ -2,13 +2,13 @@
# The apps themselves run on the host with `pnpm dev`. # The apps themselves run on the host with `pnpm dev`.
services: services:
postgres: postgres:
image: docker.arvancloud.ir/postgres:18-alpine image: postgres:15-alpine
environment: environment:
POSTGRES_USER: imen POSTGRES_USER: imen
POSTGRES_PASSWORD: imen POSTGRES_PASSWORD: imen
POSTGRES_DB: imen POSTGRES_DB: imen
ports: ports:
- "${POSTGRES_PORT:-5433}:5432" - "${POSTGRES_PORT:-5435}:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql
healthcheck: 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", "infra:down": "docker compose -f docker-compose.dev.yml down",
"db:migrate": "pnpm --filter @imen/api prisma:migrate", "db:migrate": "pnpm --filter @imen/api prisma:migrate",
"db:seed": "pnpm --filter @imen/api db:seed", "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": { "devDependencies": {
"prettier": "^3.6.2", "prettier": "^3.6.2",
+16
View File
@@ -143,6 +143,18 @@ export interface Paginated<T> {
pageSize: number; pageSize: number;
} }
export interface DashboardCategoryStat {
id: number;
name: string;
count: number;
}
export interface DashboardTrendDay {
date: string;
dayName: string;
messages: number;
}
export interface DashboardDto { export interface DashboardDto {
categories: number; categories: number;
products: number; products: number;
@@ -151,4 +163,8 @@ export interface DashboardDto {
media: number; media: number;
recentMessages: ContactMessageDto[]; recentMessages: ContactMessageDto[];
recentActivity: { id: number; action: string; entity: string; summary: string; user: string | null; createdAt: string }[]; 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) .max(80)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "فقط حروف کوچک انگلیسی، عدد و خط تیره (مثلاً safety-shoes)"); .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 */ /** Image stored by URL inside section content */
export const imageRefSchema = z.object({ 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. * Vuesax linear icon aliases and recommended picker list.
* The web app keeps a matching registry (DynamicIcon) so only these icons end up in the bundle.
*/ */
export const ICON_NAMES = [ export const ICON_ALIASES: Record<string, string> = {
"ShieldCheck", // Navigation & Actions
"ShieldPlus", ArrowLeft: "arrow-left",
"ShieldAlert", ArrowRight: "arrow-right",
"BadgeCheck", ArrowUp: "arrow-up",
"Award", ArrowDown: "arrow-down",
"Medal", ChevronDown: "arrow-down-1",
"PackageCheck", ChevronUp: "arrow-up-1",
"Package", ChevronLeft: "arrow-left-1",
"Truck", ChevronRight: "arrow-right-1",
"Warehouse", Menu: "menu",
"Globe", X: "close-circle",
"Users", Plus: "add",
"Handshake", Trash: "trash",
"Headset", Edit: "edit-2",
"ClipboardCheck", Copy: "copy",
"FileCheck", Check: "tick-circle",
"FileSearch", Search: "search-normal",
"GraduationCap", Filter: "filter",
"Gauge", Eye: "eye",
"Microscope", EyeOff: "eye-slash",
"Wrench", ImageOff: "gallery-slash",
"HardHat", Quote: "quote-up",
"Shirt",
"Footprints", // Contact & Social
"Hand", Phone: "call",
"Glasses", Mail: "sms",
"Ear", MapPin: "location",
"Wind", Clock: "clock",
"TriangleAlert", Globe: "global",
"Siren", Send: "send-2",
"TrafficCone", Instagram: "instagram",
"Construction", Linkedin: "link",
"Factory", MessageCircle: "messages-2",
"Building2", Youtube: "video-play",
"Fuel", AtSign: "sms",
"Pickaxe",
"Flame", // Equipment & Safety Categories
"Zap", HardHat: "security-safe",
"Leaf", Glasses: "glass",
"HeartPulse", Shirt: "tag",
"Clock", Footprints: "routing-2",
"Phone", Hand: "shield-tick",
"Mail", Ear: "volume-high",
"MapPin", Wind: "wind-2",
"Star", TriangleAlert: "danger",
"ThumbsUp", Siren: "lamp-on",
"Target", TrafficCone: "danger",
"Lock", 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; ] 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({ export const heroSectionSchema = z.object({
enabled: z.boolean().default(true),
badge: req(120), badge: req(120),
title: req(80), title: req(80),
titleHighlight: req(80), titleHighlight: req(80),
@@ -91,10 +92,12 @@ export const heroSectionSchema = z.object({
}); });
export const tapeSectionSchema = z.object({ export const tapeSectionSchema = z.object({
enabled: z.boolean().default(true),
items: z.array(req(80)).min(1).max(20), items: z.array(req(80)).min(1).max(20),
}); });
export const statsSectionSchema = z.object({ export const statsSectionSchema = z.object({
enabled: z.boolean().default(true),
items: z items: z
.array( .array(
z.object({ z.object({
@@ -108,12 +111,14 @@ export const statsSectionSchema = z.object({
}); });
export const productsIntroSectionSchema = z.object({ export const productsIntroSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema, head: sectionHeadSchema,
ctaLabel: req(60), ctaLabel: req(60),
trust: z.array(iconCard).max(6), trust: z.array(iconCard).max(6),
}); });
export const aboutSectionSchema = z.object({ export const aboutSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema, head: sectionHeadSchema,
body: req(2000), body: req(2000),
image: imageRefSchema, image: imageRefSchema,
@@ -123,6 +128,7 @@ export const aboutSectionSchema = z.object({
}); });
export const servicesSectionSchema = z.object({ export const servicesSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema, head: sectionHeadSchema,
items: z items: z
.array( .array(
@@ -137,6 +143,7 @@ export const servicesSectionSchema = z.object({
}); });
export const standardsSectionSchema = z.object({ export const standardsSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema, head: sectionHeadSchema,
note: text(500), note: text(500),
ctaLabel: req(80), ctaLabel: req(80),
@@ -144,6 +151,7 @@ export const standardsSectionSchema = z.object({
}); });
export const industriesSectionSchema = z.object({ export const industriesSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema, head: sectionHeadSchema,
items: z items: z
.array( .array(
@@ -158,11 +166,13 @@ export const industriesSectionSchema = z.object({
}); });
export const testimonialsSectionSchema = z.object({ export const testimonialsSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema, head: sectionHeadSchema,
items: z.array(z.object({ text: req(600), name: req(80), role: text(160) })).max(12), items: z.array(z.object({ text: req(600), name: req(80), role: text(160) })).max(12),
}); });
export const contactSectionSchema = z.object({ export const contactSectionSchema = z.object({
enabled: z.boolean().default(true),
head: sectionHeadSchema, head: sectionHeadSchema,
emergencyTitle: req(80), emergencyTitle: req(80),
emergencyText: req(400), emergencyText: req(400),
@@ -173,6 +183,7 @@ export const contactSectionSchema = z.object({
}); });
export const footerSectionSchema = z.object({ export const footerSectionSchema = z.object({
enabled: z.boolean().default(true),
about: req(500), about: req(500),
copyright: req(160), copyright: req(160),
certLine: text(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})`);
});