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:
@@ -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>
|
||||
@@ -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' : ''}`">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user