feat: polish admin UI, phone login, and product details

Switch admin auth to mobile, restyle shell/login with Vuesax icons, and add product detail fields plus public product pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
S. Esmaeili
2026-10-22 17:18:17 +03:30
co-authored by Cursor
parent 789d224ea0
commit 49f30a96b1
54 changed files with 3632 additions and 574 deletions
+3 -1
View File
@@ -17,7 +17,9 @@ useJsonLd([organizationSchema(seo), websiteSchema(seo)]);
<template>
<StyleProvider :cache="$antdCache">
<ConfigProvider :theme="antdTheme" direction="rtl" :locale="faIR">
<NuxtPage />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</ConfigProvider>
</StyleProvider>
</template>
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { AnimatePresence } from "motion-v";
import Preloader from "./Preloader.vue";
import Cursor from "./Cursor.vue";
import Header from "./Header.vue";
import BackToTop from "./BackToTop.vue";
const ui = useUiStore();
</script>
<template>
<AnimatePresence>
<Preloader v-if="!ui.ready" @complete="ui.setReady" />
</AnimatePresence>
<Cursor />
<div class="noise" aria-hidden="true" />
<Header />
<slot />
<BackToTop />
</template>
+6 -3
View File
@@ -12,8 +12,10 @@ withDefaults(
desc?: string;
center?: boolean;
headingId?: string;
/** heading level; 1 for the main title of a page (one <h1> per page) */
level?: 1 | 2;
}>(),
{ center: false },
{ center: false, level: 2 },
);
</script>
@@ -29,7 +31,8 @@ withDefaults(
</div>
</Reveal>
<Reveal :delay="0.08">
<h2
<component
:is="`h${level}`"
:id="headingId"
class="mt-5 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]"
>
@@ -40,7 +43,7 @@ withDefaults(
<span class="text-accent">{{ highlight }}</span>
</template>
</slot>
</h2>
</component>
</Reveal>
<Reveal v-if="desc" :delay="0.16">
<p :class="`mt-5 max-w-xl leading-8 text-muted ${center ? 'mx-auto' : ''}`">
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { AnimatePresence } from "motion-v";
import BackToTop from "~/components/layout/BackToTop.vue";
import Cursor from "~/components/layout/Cursor.vue";
import Footer from "~/components/layout/Footer.vue";
import Header from "~/components/layout/Header.vue";
import Preloader from "~/components/layout/Preloader.vue";
/*
* Site chrome shared by every page. It lives in the layout (not in each page) so navigation
* swaps only the page content: animations here — e.g. the preloader's exit — never run on a
* page that is being torn down, which crashed Vue's transition code.
*/
const ui = useUiStore();
</script>
<template>
<div>
<AnimatePresence>
<Preloader v-if="!ui.ready" @complete="ui.setReady" />
</AnimatePresence>
<Cursor />
<div class="noise" aria-hidden="true" />
<Header />
<slot />
<Footer />
<BackToTop />
</div>
</template>
+178 -176
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import type { PublicCategory } from "@imen/shared";
import { ArrowRight, Phone } from "lucide-vue-next";
import { ArrowLeft, ArrowRight, Phone } from "lucide-vue-next";
import { categoryPageSchema } from "#shared/seo/schema";
import AppShell from "~/components/layout/AppShell.vue";
import Footer from "~/components/layout/Footer.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
import Reveal from "~/components/ui/Reveal.vue";
@@ -56,190 +54,194 @@ const groupCount = (g: string) => category.products.filter((p) => p.group === g)
</script>
<template>
<AppShell>
<main>
<!-- ---- hero banner ---- -->
<section class="relative overflow-hidden pt-32 pb-20 md:pt-40 md:pb-28">
<div class="absolute inset-0">
<img
v-if="category.coverImage"
:src="category.coverImage"
:alt="category.name"
class="absolute inset-0 size-full object-cover"
fetchpriority="high"
decoding="async"
/>
<div class="absolute inset-0 bg-gradient-to-l from-bg via-bg/85 to-bg/40" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 to-bg/30" />
<div class="absolute inset-0 bg-grid opacity-40" />
<main>
<!-- ---- hero banner ---- -->
<section class="relative overflow-hidden pt-32 pb-20 md:pt-40 md:pb-28">
<div class="absolute inset-0">
<img
v-if="category.coverImage"
:src="category.coverImage"
:alt="category.name"
class="absolute inset-0 size-full object-cover"
fetchpriority="high"
decoding="async"
/>
<div class="absolute inset-0 bg-gradient-to-l from-bg via-bg/85 to-bg/40" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 to-bg/30" />
<div class="absolute inset-0 bg-grid opacity-40" />
</div>
<div class="tape-v absolute bottom-0 end-0 z-10 h-32 w-1.5 opacity-70" />
<div class="relative z-10 mx-auto max-w-[1480px] px-5 md:px-8">
<Reveal>
<nav class="flex items-center gap-2 text-sm text-muted" aria-label="مسیر">
<NuxtLink to="/" class="transition-colors hover:text-accent">خانه</NuxtLink>
<span class="text-line">/</span>
<NuxtLink to="/#products" class="transition-colors hover:text-accent">
محصولات
</NuxtLink>
<span class="text-line">/</span>
<span class="text-ink">{{ category.name }}</span>
</nav>
</Reveal>
<div class="mt-8">
<SectionHead
:tag="`${faNum(String(categoryIndex + 1).padStart(2, '0'))} — محصولات`"
:desc="category.fullDesc"
:level="1"
>
<template #title>{{ category.name }}</template>
</SectionHead>
</div>
<div class="tape-v absolute bottom-0 end-0 z-10 h-32 w-1.5 opacity-70" />
<div class="relative z-10 mx-auto max-w-[1480px] px-5 md:px-8">
<Reveal>
<nav class="flex items-center gap-2 text-sm text-muted" aria-label="مسیر">
<NuxtLink to="/" class="transition-colors hover:text-accent">خانه</NuxtLink>
<span class="text-line">/</span>
<NuxtLink to="/#products" class="transition-colors hover:text-accent">
محصولات
</NuxtLink>
<span class="text-line">/</span>
<span class="text-ink">{{ category.name }}</span>
</nav>
</Reveal>
<div class="mt-8">
<SectionHead
:tag="`${faNum(String(categoryIndex + 1).padStart(2, '0'))} — محصولات`"
:desc="category.fullDesc"
<Reveal :delay="0.2">
<div class="mt-8 flex flex-wrap items-center gap-4">
<span
class="rounded-full border border-accent/40 bg-accent/10 px-4 py-1.5 font-mono text-xs text-accent backdrop-blur"
>
<template #title>{{ category.name }}</template>
</SectionHead>
{{ faNum(category.products.length) }} محصول
</span>
<span class="font-mono text-xs tracking-[0.25em] text-muted/60">
{{ category.nameEn }}
</span>
</div>
</Reveal>
</div>
</section>
<Reveal :delay="0.2">
<div class="mt-8 flex flex-wrap items-center gap-4">
<span
class="rounded-full border border-accent/40 bg-accent/10 px-4 py-1.5 font-mono text-xs text-accent backdrop-blur"
>
{{ faNum(category.products.length) }} محصول
</span>
<span class="font-mono text-xs tracking-[0.25em] text-muted/60">
{{ category.nameEn }}
</span>
</div>
</Reveal>
<!-- ---- products grid ---- -->
<section class="relative py-16 md:py-24">
<div class="absolute inset-0 bg-grid-fine opacity-30" />
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<div v-if="category.groups?.length" class="mb-10">
<div class="flex flex-wrap gap-2" role="tablist" aria-label="فیلتر زیردسته">
<button
type="button"
role="tab"
:aria-selected="activeGroup === null"
class="rounded-full border px-4 py-2 text-sm transition-colors"
:class="
activeGroup === null
? 'border-accent bg-accent text-bg font-bold'
: 'border-line text-muted hover:border-accent/60 hover:text-accent'
"
@click="activeGroup = null"
>
همه
<span class="ms-1 font-mono text-xs opacity-70">{{ faNum(category.products.length) }}</span>
</button>
<button
v-for="g in category.groups"
:key="g.slug"
type="button"
role="tab"
:aria-selected="activeGroup === g.slug"
class="rounded-full border px-4 py-2 text-sm transition-colors"
:class="
activeGroup === g.slug
? 'border-accent bg-accent text-bg font-bold'
: 'border-line text-muted hover:border-accent/60 hover:text-accent'
"
@click="activeGroup = g.slug"
>
{{ g.name }}
<span class="ms-1 font-mono text-xs opacity-70">{{ faNum(groupCount(g.slug)) }}</span>
</button>
</div>
<p v-if="activeGroupDesc" class="mt-4 text-sm text-muted">{{ activeGroupDesc }}</p>
</div>
</section>
<!-- ---- products grid ---- -->
<section class="relative py-16 md:py-24">
<div class="absolute inset-0 bg-grid-fine opacity-30" />
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<div v-if="category.groups?.length" class="mb-10">
<div class="flex flex-wrap gap-2" role="tablist" aria-label="فیلتر زیردسته">
<button
type="button"
role="tab"
:aria-selected="activeGroup === null"
class="rounded-full border px-4 py-2 text-sm transition-colors"
:class="
activeGroup === null
? 'border-accent bg-accent text-bg font-bold'
: 'border-line text-muted hover:border-accent/60 hover:text-accent'
"
@click="activeGroup = null"
>
همه
<span class="ms-1 font-mono text-xs opacity-70">{{ faNum(category.products.length) }}</span>
</button>
<button
v-for="g in category.groups"
:key="g.slug"
type="button"
role="tab"
:aria-selected="activeGroup === g.slug"
class="rounded-full border px-4 py-2 text-sm transition-colors"
:class="
activeGroup === g.slug
? 'border-accent bg-accent text-bg font-bold'
: 'border-line text-muted hover:border-accent/60 hover:text-accent'
"
@click="activeGroup = g.slug"
>
{{ g.name }}
<span class="ms-1 font-mono text-xs opacity-70">{{ faNum(groupCount(g.slug)) }}</span>
</button>
</div>
<p v-if="activeGroupDesc" class="mt-4 text-sm text-muted">{{ activeGroupDesc }}</p>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Reveal v-for="(product, i) in visibleProducts" :key="product.code" :delay="i * 0.06">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Reveal v-for="(product, i) in visibleProducts" :key="product.code" :delay="i * 0.06">
<NuxtLink
:to="`/products/${product.slug}`"
class="group flex h-full flex-col overflow-hidden rounded-xl border border-line bg-panel/80 backdrop-blur transition-all duration-500 hover:border-accent/40 hover:shadow-[0_8px_40px_rgba(255,90,0,0.08)]"
>
<div
class="group h-full overflow-hidden rounded-xl border border-line bg-panel/80 backdrop-blur transition-all duration-500 hover:border-accent/40 hover:shadow-[0_8px_40px_rgba(255,90,0,0.08)]"
class="relative aspect-[4/3] overflow-hidden"
:class="product.image && 'bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.14),transparent_70%)]'"
>
<div
class="relative aspect-[4/3] overflow-hidden"
:class="product.image && 'bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.14),transparent_70%)]'"
>
<img
:src="product.image ?? category.coverImage ?? undefined"
:alt="product.name"
loading="lazy"
decoding="async"
class="size-full transition-transform duration-700 group-hover:scale-105"
:class="product.image ? 'object-contain p-8' : 'object-cover'"
/>
<div v-if="!product.image" class="absolute inset-0 bg-gradient-to-t from-bg/60 to-transparent" />
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-4">
<span class="rounded-full bg-bg/60 px-3 py-1 font-mono text-[10px] tracking-widest text-ink/70 backdrop-blur">
{{ product.code }}
</span>
<span class="font-mono text-xs text-accent">
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
</div>
</div>
<div class="p-5 md:p-6">
<h3 class="text-xl font-black">{{ product.name }}</h3>
<p class="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted">
{{ product.nameEn }}
</p>
<p class="mt-3 text-sm leading-8 text-muted">{{ product.desc }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<span
v-for="s in product.specs"
:key="s"
class="rounded-full border border-accent/40 bg-accent/10 px-3 py-1 font-mono text-[10px] text-accent"
>
{{ s }}
</span>
</div>
<div v-if="product.variants?.length" class="mt-4">
<div class="mb-2 text-xs text-muted">مدل‌ها / رنگ‌ها:</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="v in product.variants"
:key="v"
class="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-[11px] text-ink/70"
>
{{ v }}
</span>
</div>
</div>
<img
:src="product.image ?? category.coverImage ?? undefined"
:alt="product.name"
loading="lazy"
decoding="async"
class="size-full transition-transform duration-700 group-hover:scale-105"
:class="product.image ? 'object-contain p-8' : 'object-cover'"
/>
<div v-if="!product.image" class="absolute inset-0 bg-gradient-to-t from-bg/60 to-transparent" />
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-4">
<span class="rounded-full bg-bg/60 px-3 py-1 font-mono text-[10px] tracking-widest text-ink/70 backdrop-blur">
{{ product.code }}
</span>
<span class="font-mono text-xs text-accent">
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
</div>
</div>
</Reveal>
</div>
</div>
</section>
<!-- ---- bottom CTA ---- -->
<section class="border-t border-line py-12 md:py-16">
<div class="mx-auto flex max-w-[1480px] flex-wrap items-center justify-between gap-6 px-5 md:px-8">
<NuxtLink
to="/#products"
class="group inline-flex items-center gap-2 text-muted transition-colors hover:text-accent"
>
<ArrowRight class="size-4 transition-transform group-hover:translate-x-1" />
بازگشت به دسته‌بندی‌ها
</NuxtLink>
<a
href="/#contact"
class="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-bold text-bg transition-all hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
>
<Phone class="size-4" />
درخواست قیمت
</a>
<div class="flex flex-1 flex-col p-5 md:p-6">
<h3 class="text-xl font-black transition-colors group-hover:text-accent">{{ product.name }}</h3>
<p class="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted">
{{ product.nameEn }}
</p>
<p class="mt-3 text-sm leading-8 text-muted">{{ product.desc }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<span
v-for="s in product.specs"
:key="s"
class="rounded-full border border-accent/40 bg-accent/10 px-3 py-1 font-mono text-[10px] text-accent"
>
{{ s }}
</span>
</div>
<div v-if="product.variants?.length" class="mt-4">
<div class="mb-2 text-xs text-muted">مدل‌ها / رنگ‌ها:</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="v in product.variants"
:key="v"
class="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-[11px] text-ink/70"
>
{{ v }}
</span>
</div>
</div>
<span class="mt-auto inline-flex items-center gap-2 pt-5 text-sm font-bold text-accent">
مشاهده جزئیات
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-1" />
</span>
</div>
</NuxtLink>
</Reveal>
</div>
</section>
</main>
<Footer />
</AppShell>
</div>
</section>
<!-- ---- bottom CTA ---- -->
<section class="border-t border-line py-12 md:py-16">
<div class="mx-auto flex max-w-[1480px] flex-wrap items-center justify-between gap-6 px-5 md:px-8">
<NuxtLink
to="/#products"
class="group inline-flex items-center gap-2 text-muted transition-colors hover:text-accent"
>
<ArrowRight class="size-4 transition-transform group-hover:translate-x-1" />
بازگشت به دسته‌بندی‌ها
</NuxtLink>
<a
href="/#contact"
class="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-bold text-bg transition-all hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
>
<Phone class="size-4" />
درخواست قیمت
</a>
</div>
</section>
</main>
</template>
+12 -17
View File
@@ -1,6 +1,4 @@
<script setup lang="ts">
import AppShell from "~/components/layout/AppShell.vue";
import Footer from "~/components/layout/Footer.vue";
import Hero from "~/components/sections/Hero.vue";
import TapeMarquee from "~/components/sections/TapeMarquee.vue";
import Stats from "~/components/sections/Stats.vue";
@@ -28,19 +26,16 @@ useSeoMeta({
</script>
<template>
<AppShell>
<main>
<Hero />
<TapeMarquee />
<Stats />
<Products />
<About />
<Services />
<Standards />
<Industries />
<Testimonials />
<Contact />
</main>
<Footer />
</AppShell>
<main>
<Hero />
<TapeMarquee />
<Stats />
<Products />
<About />
<Services />
<Standards />
<Industries />
<Testimonials />
<Contact />
</main>
</template>
+627
View File
@@ -0,0 +1,627 @@
<script setup lang="ts">
import type { PublicProductDetail, PublicProductImage } from "@imen/shared";
import { useEventListener } from "@vueuse/core";
import {
ArrowLeft,
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
Expand,
Headset,
Phone,
X,
} from "lucide-vue-next";
import { breadcrumbSchema, productSchema } from "#shared/seo/schema";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const route = useRoute();
const slug = route.params.slug as string;
const { $api } = useNuxtApp();
const site = useSiteStore();
const contact = useContactStore();
const { data, error } = await useAsyncData(`product:${slug}`, () =>
$api<PublicProductDetail>(`/public/products/${encodeURIComponent(slug)}`),
);
if (error.value || !data.value) {
const notFound = error.value?.statusCode === 404;
throw createError({
statusCode: notFound ? 404 : 500,
statusMessage: notFound ? "محصول یافت نشد" : "خطا در دریافت اطلاعات",
fatal: true,
});
}
const product = data.value;
const categoryPath = `/categories/${product.category.slug}`;
/* ---------------- gallery ---------------- */
// products without their own photos show the category cover instead
const ownImages = product.images.length > 0;
const images: PublicProductImage[] = ownImages
? product.images
: product.category.coverImage
? [{ url: product.category.coverImage, alt: product.name, width: null, height: null }]
: [];
const active = ref(0);
const current = computed(() => images[active.value]);
const go = (step: number) => {
active.value = (active.value + step + images.length) % images.length;
};
const lightbox = ref(false);
watch(lightbox, (open) => {
document.body.style.overflow = open ? "hidden" : "";
});
onBeforeUnmount(() => {
document.body.style.overflow = "";
});
if (import.meta.client) {
useEventListener(window, "keydown", (e: KeyboardEvent) => {
if (!lightbox.value) return;
if (e.key === "Escape") lightbox.value = false;
// RTL: the left arrow moves forward
if (e.key === "ArrowLeft") go(1);
if (e.key === "ArrowRight") go(-1);
});
}
/* ---------------- content ---------------- */
/** a blank line in the admin text starts a new paragraph */
const paragraphs = product.details
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const facts = [
{ label: "برند", value: product.brand },
{ label: "کشور سازنده", value: product.origin },
{ label: "بسته‌بندی", value: product.packaging },
{ label: "حداقل سفارش", value: product.minOrder },
].filter((f) => f.value);
const specRows = [
{ name: "کد محصول", value: product.code, ltr: true },
{ name: "دسته", value: product.category.name },
...(product.groupName ? [{ name: "زیرگروه", value: product.groupName }] : []),
...(product.brand ? [{ name: "برند", value: product.brand }] : []),
...(product.origin ? [{ name: "کشور سازنده", value: product.origin }] : []),
...product.attributes,
...(product.standards.length ? [{ name: "استانداردها", value: product.standards.join("، "), ltr: true }] : []),
...(product.sizes.length ? [{ name: "سایزها", value: product.sizes.join("، ") }] : []),
...(product.variants.length ? [{ name: "مدل‌ها / رنگ‌ها", value: product.variants.join("، ") }] : []),
...(product.packaging ? [{ name: "بسته‌بندی", value: product.packaging }] : []),
...(product.minOrder ? [{ name: "حداقل سفارش", value: product.minOrder }] : []),
] as { name: string; value: string; ltr?: boolean }[];
const trust = computed(() => site.sections.productsIntro.trust);
const settings = computed(() => site.settings);
/** fills the contact form on the home page with this product, then scrolls to it */
const requestQuote = () =>
contact.prefill(`درخواست قیمت و موجودی:\n${product.name} — کد ${product.code}`);
/* ---------------- SEO ---------------- */
const absUrl = useAbsUrl();
const canonical = absUrl(`/products/${slug}`);
const title = product.seoTitle || product.name;
const description = (
product.seoDescription ||
product.desc ||
paragraphs[0] ||
product.category.fullDesc
).slice(0, 300);
// the site-wide titleTemplate appends the brand name
useHead({
title,
link: [
{ rel: "canonical", href: canonical },
...(images[0] ? [{ rel: "preload" as const, as: "image" as const, href: images[0].url }] : []),
],
});
useSeoMeta({
description,
robots: "index, follow",
ogUrl: canonical,
ogTitle: `${title} | ${site.settings.name}`,
ogDescription: description,
...(images[0] && {
ogImage: absUrl(images[0].url),
ogImageAlt: images[0].alt,
twitterImage: absUrl(images[0].url),
}),
});
const seo = useSeoContext();
useJsonLd(
[
productSchema(seo, product),
breadcrumbSchema(seo, [
{ name: "خانه", path: "/" },
{ name: product.category.name, path: categoryPath },
{ name: product.name, path: `/products/${slug}` },
]),
],
`product-${slug}`,
);
</script>
<template>
<main>
<!-- ---- product hero: gallery + summary ---- -->
<section class="relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-24">
<div class="absolute inset-0 bg-grid opacity-30" />
<div class="tape-v absolute bottom-0 end-0 z-10 h-32 w-1.5 opacity-70" />
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<Reveal>
<nav class="flex flex-wrap items-center gap-2 text-sm text-muted" aria-label="مسیر">
<NuxtLink to="/" class="transition-colors hover:text-accent">خانه</NuxtLink>
<span class="text-line">/</span>
<NuxtLink to="/#products" class="transition-colors hover:text-accent">محصولات</NuxtLink>
<span class="text-line">/</span>
<NuxtLink :to="categoryPath" class="transition-colors hover:text-accent">
{{ product.category.name }}
</NuxtLink>
<span class="text-line">/</span>
<span class="text-ink">{{ product.name }}</span>
</nav>
</Reveal>
<div class="mt-8 grid gap-10 lg:grid-cols-12 lg:gap-14">
<!-- gallery -->
<Reveal class="lg:col-span-6" :delay="0.08">
<div class="relative">
<div class="hazard absolute -top-3 -end-3 z-10 h-6 w-28 rounded-full opacity-90" />
<div class="relative overflow-hidden rounded-2xl border border-line bg-panel/80">
<div
class="group relative aspect-square overflow-hidden"
:class="ownImages && 'bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.16),transparent_70%)]'"
>
<Transition
enter-active-class="transition duration-500 ease-out"
enter-from-class="opacity-0 scale-[0.97]"
leave-active-class="absolute inset-0 transition duration-200"
leave-to-class="opacity-0"
>
<img
v-if="current"
:key="current.url"
:src="current.url"
:alt="current.alt"
:width="current.width ?? undefined"
:height="current.height ?? undefined"
fetchpriority="high"
decoding="async"
class="size-full"
:class="ownImages ? 'object-contain p-10 md:p-16' : 'object-cover'"
/>
</Transition>
<div v-if="!ownImages" class="absolute inset-0 bg-gradient-to-t from-bg/70 to-transparent" />
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-4 md:p-5">
<span class="rounded-full bg-bg/60 px-3 py-1 font-mono text-[10px] tracking-widest text-ink/70 backdrop-blur">
{{ product.code }}
</span>
<span v-if="images.length > 1" class="font-mono text-xs text-accent">
{{ faNum(String(active + 1).padStart(2, "0")) }} / {{ faNum(String(images.length).padStart(2, "0")) }}
</span>
</div>
<button
v-if="current"
type="button"
aria-label="نمایش بزرگ تصویر"
class="absolute bottom-4 start-4 flex size-11 items-center justify-center rounded-full border border-line bg-bg/70 text-ink/80 backdrop-blur transition-colors hover:border-accent hover:text-accent"
@click="lightbox = true"
>
<Expand class="size-4" />
</button>
<template v-if="images.length > 1">
<button
type="button"
aria-label="تصویر قبلی"
class="absolute start-4 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-full border border-line bg-bg/70 text-ink/80 opacity-0 backdrop-blur transition-all hover:border-accent hover:text-accent group-hover:opacity-100 max-md:opacity-100"
@click="go(-1)"
>
<ChevronRight class="size-5" />
</button>
<button
type="button"
aria-label="تصویر بعدی"
class="absolute end-4 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-full border border-line bg-bg/70 text-ink/80 opacity-0 backdrop-blur transition-all hover:border-accent hover:text-accent group-hover:opacity-100 max-md:opacity-100"
@click="go(1)"
>
<ChevronLeft class="size-5" />
</button>
</template>
</div>
</div>
<div v-if="images.length > 1" class="mt-4 flex gap-3 overflow-x-auto pb-1">
<button
v-for="(img, i) in images"
:key="img.url"
type="button"
:aria-label="`تصویر ${faNum(i + 1)}`"
:aria-current="i === active"
class="size-20 shrink-0 overflow-hidden rounded-xl border bg-panel/80 transition-all md:size-24"
:class="i === active ? 'border-accent shadow-[0_0_24px_rgba(255,90,0,0.25)]' : 'border-line opacity-60 hover:opacity-100'"
@click="active = i"
>
<img :src="img.url" :alt="img.alt" loading="lazy" class="size-full object-contain p-2" />
</button>
</div>
</div>
</Reveal>
<!-- summary -->
<div class="lg:col-span-6">
<Reveal :delay="0.12">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border border-accent/40 bg-accent/10 px-3 py-1 font-mono text-xs text-accent">
{{ product.code }}
</span>
<NuxtLink
:to="categoryPath"
class="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-accent/60 hover:text-accent"
>
{{ product.category.name }}
</NuxtLink>
<span v-if="product.groupName" class="rounded-full border border-line px-3 py-1 text-xs text-muted">
{{ product.groupName }}
</span>
</div>
<h1 class="mt-6 text-3xl font-black leading-[1.3] md:text-5xl md:leading-[1.25]">
{{ product.name }}
</h1>
<p v-if="product.nameEn" class="mt-3 font-mono text-xs tracking-[0.25em] text-muted">
{{ product.nameEn }}
</p>
<p v-if="product.desc" class="mt-6 max-w-2xl leading-9 text-ink/75">{{ product.desc }}</p>
<div v-if="product.specs.length" class="mt-6 flex flex-wrap gap-2">
<span
v-for="s in product.specs"
:key="s"
class="rounded-full border border-accent/40 bg-accent/10 px-3 py-1.5 font-mono text-[11px] text-accent"
>
{{ s }}
</span>
</div>
</Reveal>
<Reveal v-if="facts.length || product.standards.length" :delay="0.18">
<dl class="mt-8 grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-2">
<div v-for="f in facts" :key="f.label" class="bg-panel/80 p-4">
<dt class="text-xs text-muted">{{ f.label }}</dt>
<dd class="mt-1.5 font-bold">{{ f.value }}</dd>
</div>
<div v-if="product.standards.length" class="bg-panel/80 p-4 sm:col-span-2">
<dt class="text-xs text-muted">استانداردها و گواهی‌ها</dt>
<dd class="mt-2 flex flex-wrap gap-2">
<span
v-for="s in product.standards"
:key="s"
class="rounded-md border border-ink/15 px-2.5 py-1 font-mono text-xs tracking-wider text-ink/85"
dir="ltr"
>
{{ s }}
</span>
</dd>
</div>
</dl>
</Reveal>
<Reveal v-if="product.variants.length || product.sizes.length" :delay="0.22">
<div class="mt-8 space-y-5">
<div v-if="product.variants.length">
<div class="mb-2.5 text-xs text-muted">مدل‌ها / رنگ‌ها</div>
<div class="flex flex-wrap gap-2">
<span
v-for="v in product.variants"
:key="v"
class="rounded-lg border border-line bg-panel-2 px-3 py-1.5 text-sm text-ink/80"
>
{{ v }}
</span>
</div>
</div>
<div v-if="product.sizes.length">
<div class="mb-2.5 text-xs text-muted">سایزها</div>
<div class="flex flex-wrap gap-2">
<span
v-for="s in product.sizes"
:key="s"
class="min-w-11 rounded-lg border border-line bg-panel-2 px-3 py-1.5 text-center font-mono text-sm text-ink/80"
>
{{ s }}
</span>
</div>
</div>
</div>
</Reveal>
<Reveal :delay="0.26">
<div class="mt-9 flex flex-wrap items-center gap-4">
<NuxtLink
to="/#contact"
class="group inline-flex items-center gap-2.5 rounded-full bg-accent px-7 py-4 font-bold text-bg transition-all duration-300 hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
@click="requestQuote"
>
درخواست قیمت و موجودی
<ArrowLeft class="size-5 transition-transform duration-300 group-hover:-translate-x-1.5" />
</NuxtLink>
<a
:href="`tel:${settings.phone}`"
class="inline-flex items-center gap-2.5 rounded-full border border-ink/25 px-6 py-4 font-bold text-ink transition-colors duration-300 hover:border-accent hover:text-accent"
>
<Phone class="size-4" />
<span class="font-mono tracking-wider" dir="ltr">{{ settings.phoneDisplay }}</span>
</a>
</div>
</Reveal>
<Reveal v-if="trust.length" :delay="0.3">
<ul class="mt-9 grid gap-4 border-t border-line pt-7 sm:grid-cols-3">
<li v-for="t in trust" :key="t.title" class="flex items-start gap-3">
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent">
<DynamicIcon :name="t.icon" class="size-4" />
</span>
<span class="text-xs leading-6 text-ink/75">{{ t.title }}</span>
</li>
</ul>
</Reveal>
</div>
</div>
</div>
</section>
<!-- ---- description + technical specs ---- -->
<section class="relative border-t border-line py-20 md:py-28">
<div class="absolute inset-0 bg-grid-fine opacity-30" />
<div class="relative mx-auto grid max-w-[1480px] gap-14 px-5 md:px-8 lg:grid-cols-12">
<div class="lg:col-span-7">
<SectionHead
tag="توضیحات"
:title="paragraphs.length ? 'درباره این محصول' : `درباره ${product.category.name}`"
/>
<Reveal :delay="0.2">
<div class="mt-8 space-y-5 leading-9 text-ink/75">
<p v-for="(p, i) in paragraphs" :key="i" class="whitespace-pre-line">{{ p }}</p>
<p v-if="!paragraphs.length">{{ product.category.fullDesc }}</p>
</div>
</Reveal>
<div v-if="product.features.length" class="mt-14">
<Reveal>
<h3 class="flex items-center gap-3 text-xl font-black">
<span class="font-mono text-sm text-accent">//</span>
ویژگی‌های کلیدی
</h3>
</Reveal>
<ul class="mt-6 grid gap-3 sm:grid-cols-2">
<Reveal v-for="(f, i) in product.features" :key="f" :delay="i * 0.04">
<li class="flex h-full items-start gap-3 rounded-xl border border-line bg-panel/60 p-4">
<span class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent text-bg">
<Check class="size-3.5" :stroke-width="3" />
</span>
<span class="text-sm leading-7 text-ink/85">{{ f }}</span>
</li>
</Reveal>
</ul>
</div>
<div v-if="product.applications.length" class="mt-14">
<Reveal>
<h3 class="flex items-center gap-3 text-xl font-black">
<span class="font-mono text-sm text-accent">//</span>
کاربردها و محیط‌های مناسب
</h3>
</Reveal>
<Reveal :delay="0.1">
<div class="mt-6 flex flex-wrap gap-2.5">
<span
v-for="a in product.applications"
:key="a"
class="rounded-full border border-line bg-panel/60 px-4 py-2 text-sm text-ink/80"
>
{{ a }}
</span>
</div>
</Reveal>
</div>
</div>
<aside class="lg:col-span-5">
<div class="space-y-6 lg:sticky lg:top-28">
<Reveal :delay="0.1">
<div class="overflow-hidden rounded-2xl border border-line bg-panel/80">
<div class="flex items-center justify-between border-b border-line px-6 py-4">
<span class="font-mono text-xs tracking-[0.25em] text-accent">// مشخصات فنی</span>
<span class="font-mono text-[10px] tracking-[0.25em] text-muted/60">SPECIFICATIONS</span>
</div>
<dl>
<div
v-for="row in specRows"
:key="row.name"
class="grid grid-cols-5 gap-4 border-b border-line/70 px-6 py-3.5 last:border-b-0"
>
<dt class="col-span-2 text-sm text-muted">{{ row.name }}</dt>
<dd
class="col-span-3 text-sm font-medium text-ink/90"
:class="row.ltr && 'font-mono tracking-wider'"
:dir="row.ltr ? 'ltr' : undefined"
:style="row.ltr ? 'text-align: right' : undefined"
>
{{ row.value }}
</dd>
</div>
</dl>
</div>
</Reveal>
<Reveal :delay="0.18">
<div class="flex items-start gap-4 rounded-xl border border-hazard/30 bg-hazard/5 p-5">
<Headset class="size-6 shrink-0 text-hazard" />
<div>
<div class="font-bold text-hazard">مشاوره رایگان انتخاب تجهیزات</div>
<p class="mt-1.5 text-sm leading-7 text-muted">
برای انتخاب مدل و سایز مناسب محیط کار، خرید سازمانی یا دریافت نمونه، با کارشناسان ما تماس بگیرید.
</p>
<div class="mt-4 flex flex-wrap gap-3 text-sm">
<NuxtLink to="/#contact" class="font-bold text-accent hover:underline" @click="requestQuote">
ثبت درخواست
</NuxtLink>
<span class="text-line">|</span>
<a :href="`tel:${settings.phone}`" class="font-mono tracking-wider text-ink/80 hover:text-accent" dir="ltr">
{{ settings.phoneDisplay }}
</a>
</div>
</div>
</div>
</Reveal>
</div>
</aside>
</div>
</section>
<!-- ---- related products ---- -->
<section v-if="product.related.length" class="border-t border-line py-20 md:py-28">
<div class="mx-auto max-w-[1480px] px-5 md:px-8">
<div class="flex flex-wrap items-end justify-between gap-6">
<SectionHead tag="محصولات مرتبط" :title="`محصولات دیگر\n${product.category.name}`" />
<Reveal :delay="0.2">
<NuxtLink
:to="categoryPath"
class="group inline-flex items-center gap-2 rounded-full border border-line px-6 py-3 text-sm text-muted transition-colors hover:border-accent hover:text-accent"
>
مشاهده همه
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-1" />
</NuxtLink>
</Reveal>
</div>
<div class="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
<Reveal v-for="(r, i) in product.related" :key="r.code" :delay="i * 0.06">
<NuxtLink
:to="`/products/${r.slug}`"
class="group flex h-full flex-col overflow-hidden rounded-xl border border-line bg-panel/80 transition-all duration-500 hover:border-accent/40 hover:shadow-[0_8px_40px_rgba(255,90,0,0.08)]"
>
<div
class="relative aspect-[4/3] overflow-hidden"
:class="r.image && 'bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.14),transparent_70%)]'"
>
<img
:src="r.image ?? product.category.coverImage ?? undefined"
:alt="r.name"
loading="lazy"
decoding="async"
class="size-full transition-transform duration-700 group-hover:scale-105"
:class="r.image ? 'object-contain p-7' : 'object-cover'"
/>
<span class="absolute start-3 top-3 rounded-full bg-bg/60 px-2.5 py-0.5 font-mono text-[10px] tracking-widest text-ink/70 backdrop-blur">
{{ r.code }}
</span>
</div>
<div class="flex flex-1 flex-col p-5">
<h3 class="font-black transition-colors group-hover:text-accent">{{ r.name }}</h3>
<p class="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted">{{ r.nameEn }}</p>
<span class="mt-auto inline-flex items-center gap-2 pt-4 text-xs font-bold text-accent">
مشاهده محصول
<ArrowLeft class="size-3.5 transition-transform group-hover:-translate-x-1" />
</span>
</div>
</NuxtLink>
</Reveal>
</div>
</div>
</section>
<!-- ---- bottom CTA ---- -->
<section class="border-t border-line py-12 md:py-16">
<div class="mx-auto flex max-w-[1480px] flex-wrap items-center justify-between gap-6 px-5 md:px-8">
<NuxtLink
:to="categoryPath"
class="group inline-flex items-center gap-2 text-muted transition-colors hover:text-accent"
>
<ArrowRight class="size-4 transition-transform group-hover:translate-x-1" />
بازگشت به {{ product.category.name }}
</NuxtLink>
<NuxtLink
to="/#contact"
class="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-bold text-bg transition-all hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
@click="requestQuote"
>
<Phone class="size-4" />
درخواست قیمت این محصول
</NuxtLink>
</div>
</section>
<!-- ---- lightbox ---- -->
<!-- client-only: Nuxt cannot server-render a Teleport to <body>, which breaks hydration -->
<ClientOnly>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300"
enter-from-class="opacity-0"
leave-active-class="transition duration-200"
leave-to-class="opacity-0"
>
<div
v-if="lightbox && current"
class="fixed inset-0 z-[120] flex flex-col bg-bg/95 backdrop-blur-xl"
role="dialog"
aria-modal="true"
:aria-label="product.name"
@click.self="lightbox = false"
>
<div class="flex items-center justify-between px-5 py-4 md:px-8">
<span class="font-mono text-xs text-muted">
{{ faNum(active + 1) }} / {{ faNum(images.length) }} — {{ product.name }}
</span>
<button
type="button"
aria-label="بستن"
class="flex size-11 items-center justify-center rounded-lg border border-line transition-colors hover:border-accent hover:text-accent"
@click="lightbox = false"
>
<X class="size-5" />
</button>
</div>
<div class="relative flex min-h-0 flex-1 items-center justify-center px-5 pb-8" @click.self="lightbox = false">
<img :src="current.url" :alt="current.alt" class="max-h-full max-w-full object-contain" />
<template v-if="images.length > 1">
<button
type="button"
aria-label="تصویر قبلی"
class="absolute start-5 top-1/2 flex size-12 -translate-y-1/2 items-center justify-center rounded-full border border-line bg-bg/70 transition-colors hover:border-accent hover:text-accent"
@click="go(-1)"
>
<ChevronRight class="size-5" />
</button>
<button
type="button"
aria-label="تصویر بعدی"
class="absolute end-5 top-1/2 flex size-12 -translate-y-1/2 items-center justify-center rounded-full border border-line bg-bg/70 transition-colors hover:border-accent hover:text-accent"
@click="go(1)"
>
<ChevronLeft class="size-5" />
</button>
</template>
</div>
</div>
</Transition>
</Teleport>
</ClientOnly>
</main>
</template>
+5
View File
@@ -32,6 +32,11 @@ export const useContactStore = defineStore("contact", {
this.status = "error";
}
},
/** Pre-fills the message, e.g. from a product page's "request a quote" button */
prefill(message: string) {
if (this.status === "success") this.reset();
this.form.message = message;
},
reset() {
this.form = emptyForm();
this.status = "idle";
+9 -5
View File
@@ -53,11 +53,15 @@ export default defineNuxtConfig({
},
},
routeRules: {
"/": { swr: PAGE_CACHE_SECONDS },
"/categories/**": { swr: PAGE_CACHE_SECONDS },
"/sitemap.xml": { swr: 3600 },
"/manifest.webmanifest": { swr: 3600 },
// production only: in dev a cached page would serve stale HTML after code edits (hydration errors)
$production: {
routeRules: {
"/": { swr: PAGE_CACHE_SECONDS },
"/categories/**": { swr: PAGE_CACHE_SECONDS },
"/products/**": { swr: PAGE_CACHE_SECONDS },
"/sitemap.xml": { swr: 3600 },
"/manifest.webmanifest": { swr: 3600 },
},
},
nitro: {
+9 -2
View File
@@ -1,13 +1,20 @@
import type { PublicProductRef } from "@imen/shared";
interface SitemapEntry {
loc: string;
priority: string;
lastmod?: string;
}
export default defineEventHandler(async (event) => {
const categories = await fetchCategories();
const [categories, products] = await Promise.all([
fetchCategories(),
serverApi<PublicProductRef[]>("/public/products"),
]);
const pages: SitemapEntry[] = [
{ loc: "/", priority: "1" },
...categories.map((c) => ({ loc: `/categories/${c.slug}`, priority: "0.8" })),
...products.map((p) => ({ loc: `/products/${p.slug}`, priority: "0.6", lastmod: p.updatedAt })),
];
setHeader(event, "Content-Type", "application/xml; charset=utf-8");
@@ -15,7 +22,7 @@ export default defineEventHandler(async (event) => {
const urls = pages
.map(
(p) =>
`<url>\n<loc>${absUrl(p.loc)}</loc>\n<lastmod>${lastmod}</lastmod>\n<changefreq>weekly</changefreq>\n<priority>${p.priority}</priority>\n</url>`,
`<url>\n<loc>${absUrl(p.loc)}</loc>\n<lastmod>${p.lastmod ?? lastmod}</lastmod>\n<changefreq>weekly</changefreq>\n<priority>${p.priority}</priority>\n</url>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
+35 -1
View File
@@ -1,4 +1,4 @@
import type { PublicCategory, SiteSettings } from "@imen/shared";
import type { PublicCategory, PublicProductDetail, SiteSettings } from "@imen/shared";
import { siteConfig } from "../config/site";
type Json = Record<string, unknown>;
@@ -97,12 +97,46 @@ export const categoryPageSchema = (ctx: SeoContext, category: PublicCategory) =>
name: p.name,
description: p.desc,
sku: p.code,
url: abs(ctx, `/products/${p.slug}`),
...(p.image && { image: abs(ctx, p.image) }),
},
})),
},
});
export const productSchema = (ctx: SeoContext, p: PublicProductDetail) =>
clean({
"@context": "https://schema.org",
"@type": "Product",
"@id": `${ctx.siteUrl}/products/${p.slug}#product`,
name: p.name,
alternateName: p.nameEn,
sku: p.code,
mpn: p.code,
description: p.seoDescription || p.desc || p.details.slice(0, 300),
url: abs(ctx, `/products/${p.slug}`),
image: p.images.map((i) => abs(ctx, i.url)),
category: p.category.name,
...(p.brand && { brand: { "@type": "Brand", name: p.brand } }),
countryOfOrigin: p.origin,
additionalProperty: [
...p.attributes.map((a) => ({ "@type": "PropertyValue", name: a.name, value: a.value })),
...p.standards.map((s) => ({ "@type": "PropertyValue", name: "استاندارد", value: s })),
],
seller: { "@id": `${ctx.siteUrl}/#organization` },
});
export const breadcrumbSchema = (ctx: SeoContext, items: { name: string; path: string }[]) => ({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((it, i) => ({
"@type": "ListItem",
position: i + 1,
name: it.name,
item: abs(ctx, it.path),
})),
});
/** Serialize JSON-LD safely for an inline <script> (escape "<"). */
export const toJsonLd = (data: Json) =>
JSON.stringify(data).replace(/</g, "\\u003c");