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