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

This commit is contained in:
s.esmaeili
2026-10-23 21:06:15 +03:30
parent 49f30a96b1
commit fa61a76444
64 changed files with 4527 additions and 819 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { ArrowUp } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const show = useScrolledPast(700);
</script>
@@ -16,7 +16,7 @@ const show = useScrolledPast(700);
:exit="{ opacity: 0, y: 24 }"
class="fixed bottom-6 left-6 z-[70] flex size-12 items-center justify-center rounded-full border border-line bg-panel/90 text-ink shadow-[0_10px_40px_rgba(0,0,0,0.5)] backdrop-blur transition-colors hover:border-accent hover:text-accent"
>
<ArrowUp class="size-5" />
<VuesaxIcon name="arrow-up" class="size-5" />
</motion.a>
</AnimatePresence>
</template>
+29 -32
View File
@@ -1,45 +1,42 @@
<script setup lang="ts">
import type { SocialType } from "@imen/shared";
import {
AtSign,
Globe,
Instagram,
Linkedin,
MessageCircle,
Phone,
Send,
Youtube,
} from "lucide-vue-next";
import type { Component } from "vue";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import Logo from "~/components/ui/Logo.vue";
const QUICK_LINKS = [
{ href: "/#home", label: "خانه" },
{ href: "/#products", label: "محصولات" },
{ href: "/#about", label: "درباره ما" },
{ href: "/#services", label: "خدمات" },
{ href: "/#standards", label: "استانداردها" },
{ href: "/#industries", label: "صنایع" },
];
const ALL_QUICK_LINKS = [
{ href: "/#home", label: "خانه", section: "hero" },
{ href: "/#products", label: "محصولات", section: "productsIntro" },
{ href: "/#about", label: "درباره ما", section: "about" },
{ href: "/#services", label: "خدمات", section: "services" },
{ href: "/#standards", label: "استانداردها", section: "standards" },
{ href: "/#industries", label: "صنایع", section: "industries" },
] as const;
const SOCIAL_META: Record<SocialType, { icon: Component; label: string }> = {
website: { icon: Globe, label: "وب‌سایت" },
telegram: { icon: Send, label: "تلگرام" },
instagram: { icon: Instagram, label: "اینستاگرام" },
linkedin: { icon: Linkedin, label: "لینکدین" },
whatsapp: { icon: MessageCircle, label: "واتساپ" },
bale: { icon: MessageCircle, label: "بله" },
eitaa: { icon: MessageCircle, label: "ایتا" },
aparat: { icon: Youtube, label: "آپارات" },
youtube: { icon: Youtube, label: "یوتیوب" },
email: { icon: AtSign, label: "ایمیل" },
phone: { icon: Phone, label: "تماس" },
const SOCIAL_META: Record<SocialType, { icon: string; label: string }> = {
website: { icon: "global", label: "وب‌سایت" },
telegram: { icon: "send-2", label: "تلگرام" },
instagram: { icon: "instagram", label: "اینستاگرام" },
linkedin: { icon: "link", label: "لینکدین" },
whatsapp: { icon: "messages-2", label: "واتساپ" },
bale: { icon: "messages-2", label: "بله" },
eitaa: { icon: "messages-2", label: "ایتا" },
aparat: { icon: "video-play", label: "آپارات" },
youtube: { icon: "video-play", label: "یوتیوب" },
email: { icon: "sms", label: "ایمیل" },
phone: { icon: "call", label: "تماس" },
};
const site = useSiteStore();
const footer = computed(() => site.sections.footer);
const settings = computed(() => site.settings);
const QUICK_LINKS = computed(() =>
ALL_QUICK_LINKS.filter(
(l) =>
(site.sections as Record<string, { enabled?: boolean }>)?.[l.section]?.enabled !== false,
),
);
const PRODUCT_LINKS = computed(() =>
site.categories.map((c) => ({ label: c.name, href: `/categories/${c.slug}` })),
);
@@ -75,7 +72,7 @@ const SOCIALS = computed(() =>
:rel="s.external ? 'noopener' : undefined"
class="flex size-10 items-center justify-center rounded-lg border border-line text-muted transition-all duration-300 hover:border-accent hover:bg-accent hover:text-bg"
>
<component :is="s.icon" class="size-4" />
<VuesaxIcon :name="s.icon" class="size-4" />
</a>
</div>
</div>
+25 -15
View File
@@ -1,20 +1,30 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { Menu, Phone, X } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import Logo from "~/components/ui/Logo.vue";
const LINKS = [
{ href: "/#home", label: "خانه" },
{ href: "/#products", label: "محصولات" },
{ href: "/#about", label: "درباره ما" },
{ href: "/#services", label: "خدمات" },
{ href: "/#standards", label: "استانداردها" },
{ href: "/#industries", label: "صنایع" },
{ href: "/#contact", label: "تماس" },
];
const ALL_LINKS = [
{ href: "/#home", label: "خانه", section: "hero" },
{ href: "/#products", label: "محصولات", section: "productsIntro" },
{ href: "/#about", label: "درباره ما", section: "about" },
{ href: "/#services", label: "خدمات", section: "services" },
{ href: "/#standards", label: "استانداردها", section: "standards" },
{ href: "/#industries", label: "صنایع", section: "industries" },
{ href: "/#contact", label: "تماس", section: "contact" },
] as const;
const ui = useUiStore();
const settings = computed(() => useSiteStore().settings);
const site = useSiteStore();
const settings = computed(() => site.settings);
const sections = computed(() => site.sections);
const LINKS = computed(() =>
ALL_LINKS.filter(
(l) =>
(sections.value as Record<string, { enabled?: boolean }>)?.[l.section]?.enabled !== false,
),
);
const scrolled = useScrolledPast(40);
const { time, shift } = useTehranClock();
@@ -69,7 +79,7 @@ onBeforeUnmount(() => {
href="/#contact"
class="hidden items-center gap-2 rounded-full bg-accent px-5 py-2.5 text-sm font-bold text-bg transition-all duration-300 hover:bg-accent-soft hover:shadow-[0_0_30px_rgba(255,90,0,0.4)] md:flex"
>
<Phone class="size-4" />
<VuesaxIcon name="call" class="size-4" />
درخواست مشاوره
</a>
@@ -78,7 +88,7 @@ onBeforeUnmount(() => {
class="flex size-11 items-center justify-center rounded-lg border border-line text-ink transition-colors hover:border-accent hover:text-accent lg:hidden"
@click="ui.openMenu"
>
<Menu class="size-5" />
<VuesaxIcon name="menu" class="size-5" />
</button>
</div>
</div>
@@ -100,7 +110,7 @@ onBeforeUnmount(() => {
class="flex size-11 items-center justify-center rounded-lg border border-line transition-colors hover:border-accent hover:text-accent"
@click="ui.closeMenu"
>
<X class="size-5" />
<VuesaxIcon name="close-circle" class="size-5" />
</button>
</div>
@@ -126,7 +136,7 @@ onBeforeUnmount(() => {
<div class="flex items-center justify-between border-t border-line p-8 text-sm">
<div class="flex items-center gap-3 text-muted">
<Phone class="size-4 text-accent" />
<VuesaxIcon name="call" class="size-4 text-accent" />
<a :href="`tel:${settings.phone}`" class="font-mono" dir="ltr">{{ settings.phoneDisplay }}</a>
</div>
<div class="text-xs text-muted">تهران — شیفت {{ shift }}، {{ time }}</div>
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { motion } from "motion-v";
import { ShieldCheck } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const STEPS = [
"بررسی سیستم‌های ایمنی",
@@ -51,7 +51,7 @@ const step = computed(
<div class="flex items-center gap-4">
<span class="flex size-12 items-center justify-center rounded-xl bg-accent text-bg">
<ShieldCheck class="size-7" />
<VuesaxIcon name="shield-tick" class="size-7" />
</span>
<span class="font-mono text-xs tracking-[0.35em] text-muted">
IMEN PISHGAMAN CO.
+10 -19
View File
@@ -1,16 +1,7 @@
<script setup lang="ts">
import { Button as AButton, Form as AForm, FormItem as AFormItem, Input as AInput, Textarea as ATextarea } from "ant-design-vue";
import type { Rule } from "ant-design-vue/es/form";
import {
CheckCircle2,
Clock,
Loader2,
Mail,
MapPin,
Phone,
Send,
Siren,
} from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import Lines from "~/components/ui/Lines.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
@@ -21,10 +12,10 @@ const content = computed(() => site.sections.contact);
const INFO = computed(() => {
const s = site.settings;
return [
{ icon: MapPin, label: "نشانی", value: `${s.address.locality}، ${s.address.street}` },
{ icon: Phone, label: "تلفن تماس", value: s.phoneDisplay, ltr: true, href: `tel:${s.phone}` },
{ icon: Mail, label: "ایمیل", value: s.email, ltr: true, href: `mailto:${s.email}` },
{ icon: Clock, label: "ساعات کاری", value: s.workingHours },
{ icon: "location", label: "نشانی", value: `${s.address.locality}، ${s.address.street}` },
{ icon: "call", label: "تلفن تماس", value: s.phoneDisplay, ltr: true, href: `tel:${s.phone}` },
{ icon: "sms", label: "ایمیل", value: s.email, ltr: true, href: `mailto:${s.email}` },
{ icon: "clock", label: "ساعات کاری", value: s.workingHours },
];
});
@@ -61,7 +52,7 @@ const contact = useContactStore();
<span
class="flex size-11 shrink-0 items-center justify-center rounded-lg border border-line text-accent transition-colors duration-300 group-hover:border-accent/50"
>
<component :is="item.icon" class="size-5" />
<VuesaxIcon :name="item.icon" class="size-5" />
</span>
<div class="min-w-0">
<div class="text-xs text-muted">{{ item.label }}</div>
@@ -80,7 +71,7 @@ const contact = useContactStore();
<Reveal :delay="0.45">
<div class="mt-8 flex items-start gap-4 rounded-xl border border-hazard/30 bg-hazard/5 p-5">
<Siren class="size-6 shrink-0 text-hazard" />
<VuesaxIcon name="lamp-on" class="size-6 shrink-0 text-hazard" />
<div>
<div class="font-bold text-hazard">{{ content.emergencyTitle }}</div>
<p class="mt-1.5 text-sm leading-7 text-muted">
@@ -105,7 +96,7 @@ const contact = useContactStore();
v-if="contact.status === 'success'"
class="mt-10 flex flex-col items-center rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-10 text-center"
>
<CheckCircle2 class="size-14 text-emerald-400" />
<VuesaxIcon name="tick-circle" class="size-14 text-emerald-400" />
<div class="mt-5 text-xl font-black">{{ content.successTitle }}</div>
<p class="mt-2.5 max-w-sm text-sm leading-7 text-muted">
{{ content.successText }}
@@ -176,11 +167,11 @@ const contact = useContactStore();
:disabled="contact.status === 'loading'"
>
<template v-if="contact.status === 'loading'">
<Loader2 class="size-5 animate-spin" />
<VuesaxIcon name="refresh-circle" class="size-5 animate-spin" />
در حال ارسال...
</template>
<template v-else>
<Send class="size-5" />
<VuesaxIcon name="send-2" class="size-5" />
ارسال درخواست مشاوره
</template>
</AButton>
+39 -83
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ArrowLeft, ChevronDown, HardHat } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
const hero = computed(() => useSiteStore().sections.hero);
@@ -16,68 +16,81 @@ const hero = computed(() => useSiteStore().sections.hero);
height="1008"
fetchpriority="high"
decoding="async"
class="absolute inset-0 size-full object-cover"
class="absolute inset-0 size-full object-cover object-[center_15%] sm:object-right-top"
/>
<div class="absolute inset-0 bg-gradient-to-l from-bg via-bg/80 to-bg/25" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-transparent to-bg/60" />
<div class="absolute inset-0 bg-grid opacity-50" />
<!-- Mobile Gradient: 0 at the top so image is fully clear, reaches 100% at the texts -->
<div
class="sm:hidden absolute inset-0 pointer-events-none"
style="background: linear-gradient(to bottom, transparent 0%, transparent 26%, rgba(11, 18, 36, 0.45) 42%, rgba(11, 18, 36, 0.95) 54%, #0b1224 62%, #0b1224 100%);"
/>
<!-- Desktop Gradient: Right side dark for text, left side fading out as requested -->
<div class="hidden sm:block absolute inset-0 bg-gradient-to-l from-bg via-bg/65 via-25% to-transparent" />
<div class="hidden sm:block absolute inset-0 bg-gradient-to-t from-bg via-transparent to-bg/50" />
<div class="hidden sm:block absolute inset-0 bg-grid opacity-30" />
</div>
<!-- ---- side tape ---- -->
<div class="tape-v absolute bottom-0 end-0 z-10 h-44 w-1.5 opacity-70" />
<div class="tape-v absolute bottom-0 end-0 z-10 h-32 sm:h-44 w-1 sm:w-1.5 opacity-70" />
<!-- ---- content ---- -->
<div
class="relative z-10 mx-auto flex w-full max-w-[1480px] flex-1 items-center px-5 pb-24 pt-36 md:px-8"
class="relative z-10 mx-auto flex w-full max-w-[1480px] flex-1 items-end sm:items-center px-4 pb-6 pt-32 sm:pt-32 md:pt-36 sm:px-6 md:px-8 md:pb-24"
>
<div class="grid w-full items-center gap-12 lg:grid-cols-12">
<div class="lg:col-span-7">
<div class="w-full max-w-3xl">
<div>
<!-- Badge -->
<div
class="animate-fade-up-1 inline-flex items-center gap-2.5 rounded-full border border-accent/30 bg-accent/10 px-4 py-2 backdrop-blur"
class="animate-fade-up-1 inline-flex items-center gap-1.5 sm:gap-2.5 rounded-full border border-accent/30 bg-accent/10 px-3 py-1 sm:px-4 sm:py-2 backdrop-blur"
>
<span class="size-1.5 animate-pulse rounded-full bg-accent" />
<span class="text-xs font-medium text-accent">
<span class="text-[11px] sm:text-xs font-medium text-accent">
{{ hero.badge }}
</span>
</div>
<!-- Title & Highlight -->
<h1
class="animate-fade-up-2 mt-7 text-5xl font-black leading-[1.15] md:text-7xl md:leading-[1.1]"
class="animate-fade-up-2 mt-3 sm:mt-7 text-2xl sm:text-4xl md:text-6xl font-black leading-tight sm:leading-[1.15] md:leading-[1.1]"
>
{{ hero.title }}
<br />
<span
class="mt-3 inline-block -rotate-1 rounded-lg bg-hazard px-4 py-1.5 text-bg shadow-[0_10px_50px_rgba(255,184,0,0.25)]"
class="mt-2 inline-block whitespace-nowrap rounded-md bg-hazard px-2.5 py-1 text-lg font-black text-bg shadow-sm sm:mt-3 sm:rounded-lg sm:px-4 sm:py-1.5 sm:text-3xl sm:-rotate-1 sm:shadow-[0_10px_50px_rgba(255,184,0,0.25)] md:text-5xl"
>
{{ hero.titleHighlight }}
</span>
</h1>
<!-- Intro text -->
<p
class="animate-fade-up-3 mt-7 max-w-xl text-base leading-9 text-ink/70 md:text-lg md:leading-10"
class="animate-fade-up-3 mt-3 sm:mt-6 max-w-xl text-[12px] sm:text-base leading-relaxed sm:leading-9 text-ink/75 md:text-lg md:leading-10"
>
{{ hero.intro }}
</p>
<div class="animate-fade-up-4 mt-9 flex flex-wrap items-center gap-4">
<!-- CTA buttons -->
<div class="animate-fade-up-4 mt-5 sm:mt-9 flex flex-wrap items-center gap-2.5 sm:gap-4">
<a
:href="hero.primaryCta.href"
class="group inline-flex items-center gap-2.5 rounded-full bg-accent px-7 py-4 font-bold text-bg transition-all duration-300 hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
class="group inline-flex items-center gap-1.5 sm:gap-2.5 rounded-full bg-accent px-4 py-2.5 sm:px-7 sm:py-4 text-xs sm:text-sm md:text-base font-bold text-bg transition-all duration-300 hover:bg-accent-soft hover:shadow-[0_8px_45px_rgba(255,90,0,0.5)]"
>
{{ hero.primaryCta.label }}
<ArrowLeft
class="size-5 transition-transform duration-300 group-hover:-translate-x-1.5"
<VuesaxIcon
name="arrow-left"
class="size-3.5 sm:size-5 transition-transform duration-300 group-hover:-translate-x-1.5"
/>
</a>
<a
:href="hero.secondaryCta.href"
class="inline-flex items-center gap-2.5 rounded-full border border-ink/25 px-7 py-4 font-bold text-ink backdrop-blur transition-colors duration-300 hover:border-accent hover:text-accent"
class="inline-flex items-center gap-2 rounded-full border border-ink/25 px-4 py-2.5 sm:px-7 sm:py-4 text-xs sm:text-sm md:text-base font-bold text-ink backdrop-blur transition-colors duration-300 hover:border-accent hover:text-accent"
>
{{ hero.secondaryCta.label }}
</a>
</div>
<div class="animate-fade-up-5 mt-10 flex flex-wrap gap-3">
<!-- Feature Chips (desktop/tablet only to keep mobile clean) -->
<div class="animate-fade-up-5 mt-6 sm:mt-10 hidden sm:flex flex-wrap gap-3">
<span
v-for="c in hero.chips"
:key="c.label"
@@ -88,84 +101,27 @@ const hero = computed(() => useSiteStore().sections.hero);
</span>
</div>
</div>
<!-- ---- rotating badge + status card ---- -->
<div class="hidden flex-col items-center gap-10 lg:col-span-5 lg:flex">
<div class="animate-fade-up-6 relative">
<div class="relative size-44">
<div class="animate-spin-slow absolute inset-0">
<svg viewBox="0 0 120 120" class="size-full">
<defs>
<path
id="badge-circle"
d="M60,60 m-47,0 a47,47 0 1,1 94,0 a47,47 0 1,1 -94,0"
/>
</defs>
<text font-size="9" letter-spacing="2.6" class="fill-ink/60 font-mono">
<textPath href="#badge-circle">
IMEN PISHGAMAN SAFETY • ISO 9001 • CE CERTIFIED • EST 2007 •
</textPath>
</text>
</svg>
</div>
<div class="absolute inset-0 flex items-center justify-center">
<span
class="flex size-20 items-center justify-center rounded-full border border-accent/30 bg-accent/10"
>
<HardHat class="size-9 text-accent" />
</span>
</div>
</div>
</div>
<div
class="animate-fade-up-7 w-full max-w-sm border border-line bg-bg/70 p-5 backdrop-blur-md"
>
<div class="flex items-center justify-between font-mono text-[11px]">
<span class="text-muted">SITE STATUS</span>
<span class="flex items-center gap-2 text-emerald-400">
<span class="size-1.5 animate-pulse rounded-full bg-emerald-400" />
SECURE
</span>
</div>
<div class="mt-3.5 flex items-center justify-between font-mono text-[11px]">
<span class="text-muted">PPE COMPLIANCE</span>
<span class="font-semibold text-accent">۱۰۰٪</span>
</div>
<div
v-if="hero.lastInspection"
class="mt-3.5 flex items-center justify-between font-mono text-[11px]"
>
<span class="text-muted">آخرین بازرسی</span>
<span>{{ hero.lastInspection }}</span>
</div>
<div class="my-4 h-px bg-line" />
<div class="font-mono text-[11px] leading-6 text-muted">
سیستم پایش حرفه‌ای ایمن پیشگامان<span class="animate-blink text-accent">▍</span>
</div>
</div>
</div>
</div>
</div>
<!-- ---- scroll hint ---- -->
<div class="animate-fade-up-9 absolute bottom-28 left-1/2 z-10 hidden md:block">
<div class="animate-fade-up-9 absolute bottom-24 sm:bottom-28 left-1/2 z-10 hidden md:block">
<div class="-translate-x-1/2 flex flex-col items-center gap-2">
<span class="font-mono text-[10px] tracking-[0.35em] text-muted">SCROLL</span>
<ChevronDown class="size-4 animate-bounce text-accent" />
<VuesaxIcon name="arrow-down-1" class="size-4 animate-bounce text-accent" />
</div>
</div>
<!-- ---- bottom bar ---- -->
<div class="animate-fade-up-8 relative z-10 border-t border-ink/10 backdrop-blur-sm">
<div class="mx-auto grid max-w-[1480px] grid-cols-1 gap-px bg-ink/10 sm:grid-cols-3">
<div class="mx-auto grid max-w-[1480px] grid-cols-3 gap-px bg-ink/10">
<div
v-for="s in hero.miniStats"
:key="s.label"
class="flex items-baseline gap-4 bg-bg/60 px-6 py-5 backdrop-blur-md md:px-8"
class="flex flex-col sm:flex-row sm:items-baseline sm:gap-4 bg-bg/60 px-2.5 py-3 text-center sm:text-right backdrop-blur-md sm:px-6 sm:py-5 md:px-8"
>
<span class="text-2xl font-black text-ink md:text-3xl">{{ s.value }}</span>
<span class="text-xs leading-5 text-muted md:text-sm">{{ s.label }}</span>
<span class="text-base font-black text-ink sm:text-2xl md:text-3xl">{{ s.value }}</span>
<span class="mt-0.5 sm:mt-0 text-[10px] sm:text-xs leading-tight text-muted md:text-sm">{{ s.label }}</span>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence, motion, useMotionValue, useSpring } from "motion-v";
import { ArrowLeft } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const industries = computed(() => useSiteStore().sections.industries);
@@ -59,7 +59,7 @@ const onMove = (e: MouseEvent) => {
<span
class="flex size-10 items-center justify-center rounded-full border border-line text-muted transition-all duration-300 group-hover:-translate-x-1.5 group-hover:border-accent group-hover:text-accent md:size-12"
>
<ArrowLeft class="size-4 md:size-5" />
<VuesaxIcon name="arrow-left" class="size-4 md:size-5" />
</span>
</div>
</motion.div>
+149 -37
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ArrowLeft } from "lucide-vue-next";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
@@ -8,11 +7,65 @@ const site = useSiteStore();
const intro = computed(() => site.sections.productsIntro);
const categories = computed(() => site.categories);
// Desktop active accordion index
const active = ref(0);
// Mobile interactive state
const activeIndex = ref(0);
const scrollContainer = ref<HTMLElement | null>(null);
function scrollToCategory(index: number) {
activeIndex.value = index;
if (!scrollContainer.value) return;
const cards = scrollContainer.value.children;
if (cards[index]) {
(cards[index] as HTMLElement).scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
}
}
function handleScroll() {
if (!scrollContainer.value) return;
const el = scrollContainer.value;
const containerRect = el.getBoundingClientRect();
const centerX = containerRect.left + containerRect.width / 2;
let closestIndex = 0;
let minDistance = Infinity;
Array.from(el.children).forEach((child, i) => {
const rect = (child as HTMLElement).getBoundingClientRect();
const childCenterX = rect.left + rect.width / 2;
const dist = Math.abs(centerX - childCenterX);
if (dist < minDistance) {
minDistance = dist;
closestIndex = i;
}
});
activeIndex.value = closestIndex;
}
function nextCategory() {
const count = categories.value.length;
if (count === 0) return;
const next = (activeIndex.value + 1) % count;
scrollToCategory(next);
}
function prevCategory() {
const count = categories.value.length;
if (count === 0) return;
const prev = (activeIndex.value - 1 + count) % count;
scrollToCategory(prev);
}
</script>
<template>
<section id="products" aria-labelledby="products-title" class="relative py-24 md:py-32">
<section id="products" aria-labelledby="products-title" class="relative py-20 sm:py-24 md:py-32">
<div class="mx-auto max-w-[1480px] px-5 md:px-8">
<div class="flex flex-wrap items-end justify-between gap-6">
<SectionHead heading-id="products-title" v-bind="intro.head" />
@@ -28,38 +81,96 @@ const active = ref(0);
</div>
</div>
<!-- ---- mobile: card grid ---- -->
<div class="mx-auto mt-14 max-w-[1480px] px-5 md:hidden">
<div class="grid grid-cols-2 gap-3">
<Reveal v-for="(cat, i) in categories" :key="cat.code" :delay="i * 0.05">
<NuxtLink
:to="`/categories/${cat.slug}`"
class="group relative block aspect-[3/4] overflow-hidden rounded-xl border border-line transition-colors duration-300 active:border-accent/60"
<!-- ---- mobile: compact interactive deck ---- -->
<div class="mx-auto mt-4 max-w-[1480px] px-4 md:hidden">
<!-- Snapping Cards Deck (compact size so more of side cards are visible) -->
<div
ref="scrollContainer"
class="flex items-center gap-4 overflow-x-auto snap-x snap-mandatory no-scrollbar -mx-4 px-8 py-8 scroll-smooth"
@scroll.passive="handleScroll"
>
<div
v-for="(cat, i) in categories"
:key="cat.code"
:class="`w-[54vw] max-w-[215px] aspect-[4/5] snap-center shrink-0 relative overflow-hidden rounded-xl border transition-all duration-500 ease-out ${
activeIndex === i
? 'border-accent/90 shadow-[0_0_20px_rgba(255,90,0,0.36)] ring-1 ring-accent/30 scale-100'
: 'border-line/70 opacity-55 scale-[0.92]'
}`"
>
<!-- Background Image -->
<img
v-if="cat.coverImage"
:src="cat.coverImage"
:alt="cat.name"
loading="lazy"
decoding="async"
:class="`absolute inset-0 size-full object-cover transition-transform duration-700 ${
activeIndex === i ? 'scale-105' : 'scale-100'
}`"
/>
<div v-else class="absolute inset-0 bg-panel" />
<!-- Clean Dark Gradient for Text Legibility -->
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 via-50% to-bg/15" />
<!-- Industrial Watermark Number in background -->
<div
class="absolute -top-2 end-1.5 font-mono text-[56px] font-black leading-none text-ink/5 select-none pointer-events-none"
>
<img
v-if="cat.coverImage"
:src="cat.coverImage"
:alt="cat.name"
loading="lazy"
decoding="async"
class="absolute inset-0 size-full object-cover transition-transform duration-500 group-active:scale-105"
/>
<div v-else class="absolute inset-0 bg-panel" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 to-transparent" />
{{ String(i + 1).padStart(2, "0") }}
</div>
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-3">
<span class="font-mono text-[9px] tracking-widest text-ink/50">{{ cat.code }}</span>
<span class="font-mono text-[10px] text-accent">
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
</div>
<!-- Top Row Badges -->
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-2.5 z-10">
<span class="inline-flex items-center gap-1 rounded-full border border-ink/20 bg-bg/80 px-1.5 py-0.5 font-mono text-[8.5px] font-bold text-accent backdrop-blur-md">
<span class="size-1 rounded-full bg-accent animate-pulse" />
{{ cat.code }}
</span>
<span class="rounded-full bg-bg/75 border border-ink/10 px-1.5 py-0.5 font-mono text-[8.5px] font-bold text-ink/80 backdrop-blur-md">
{{ faNum(String(i + 1).padStart(2, "0")) }} / {{ faNum(String(categories.length).padStart(2, "0")) }}
</span>
</div>
<div class="absolute inset-x-0 bottom-0 p-3">
<h3 class="text-sm font-black leading-6">{{ cat.name }}</h3>
<p class="mt-0.5 font-mono text-[8px] tracking-[0.15em] text-muted">{{ cat.nameEn }}</p>
</div>
</NuxtLink>
</Reveal>
<!-- Bottom Content Area -->
<div class="absolute inset-x-0 bottom-0 p-3 z-10">
<span class="font-mono text-[8.5px] tracking-[0.15em] text-hazard uppercase">
{{ cat.nameEn }}
</span>
<h3 class="mt-0.5 text-xs sm:text-sm font-black text-ink leading-tight line-clamp-1">
{{ cat.name }}
</h3>
<!-- Action Button -->
<NuxtLink
:to="`/categories/${cat.slug}`"
class="mt-2 inline-flex w-full items-center justify-between rounded-md bg-accent px-2.5 py-1.5 text-[10.5px] font-bold text-bg transition-all duration-300 hover:bg-accent-soft active:scale-[0.98] shadow-sm shadow-accent/25"
>
<span>مشاهده محصولات</span>
<ArrowLeft class="size-3" />
</NuxtLink>
</div>
</div>
</div>
<!-- Navigation Arrows Only (RTL correct: Left arrow scrolls left to next card, Right arrow scrolls right to previous card) -->
<div class="mt-1 flex items-center justify-center gap-3">
<button
type="button"
aria-label="دسته قبلی (سمت راست)"
class="flex size-10 items-center justify-center rounded-full border border-line bg-panel text-ink transition-colors hover:border-accent hover:text-accent active:scale-95 shadow-sm"
@click="prevCategory"
>
<ArrowRight class="size-4" />
</button>
<button
type="button"
aria-label="دسته بعدی (سمت چپ)"
class="flex size-10 items-center justify-center rounded-full border border-line bg-panel text-ink transition-colors hover:border-accent hover:text-accent active:scale-95 shadow-sm"
@click="nextCategory"
>
<ArrowLeft class="size-4" />
</button>
</div>
</div>
@@ -96,7 +207,7 @@ const active = ref(0);
:class="`absolute inset-0 transition-opacity duration-700 ${
active === i
? 'bg-gradient-to-t from-bg via-bg/35 to-bg/5'
: 'bg-gradient-to-t from-bg/95 via-bg/30 to-transparent'
: 'bg-gradient-to-t from-bg/95 via-bg/0 to-transparent'
}`"
/>
@@ -125,19 +236,20 @@ const active = ref(0);
</div>
</div>
<!-- ---- trust section ---- -->
<div
class="mx-auto mt-10 grid max-w-[1480px] gap-px overflow-hidden rounded-xl border border-line bg-line md:grid-cols-3"
>
<Reveal v-for="(t, i) in intro.trust" :key="t.title" :delay="i * 0.08">
<div class="flex h-full items-center gap-4 bg-panel/60 p-6">
<div class="flex h-full items-center gap-4 bg-panel/60 p-4 sm:p-6">
<span
class="flex size-12 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent"
class="flex size-11 sm:size-12 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent"
>
<DynamicIcon :name="t.icon" class="size-6" />
<DynamicIcon :name="t.icon" class="size-5 sm:size-6" />
</span>
<div>
<div class="font-bold">{{ t.title }}</div>
<div class="mt-1 text-xs leading-6 text-muted">{{ t.desc }}</div>
<div class="font-bold text-sm sm:text-base">{{ t.title }}</div>
<div class="mt-1 text-xs leading-5 sm:leading-6 text-muted">{{ t.desc }}</div>
</div>
</div>
</Reveal>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { Plus } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import DynamicIcon from "~/components/ui/DynamicIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
@@ -50,7 +50,7 @@ const open = ref(0);
: 'border-line text-muted group-hover:border-accent/50 group-hover:text-accent'
}`"
>
<Plus class="size-5" />
<VuesaxIcon name="add" class="size-5" />
</span>
</button>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { FileSearch, ShieldCheck } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
@@ -26,7 +26,7 @@ const standards = computed(() => useSiteStore().sections.standards);
<SectionHead heading-id="standards-title" v-bind="standards.head" />
<Reveal v-if="standards.note" :delay="0.25">
<div class="mt-8 flex items-start gap-4 rounded-xl border border-accent/25 bg-accent/5 p-5">
<FileSearch class="size-6 shrink-0 text-accent" />
<VuesaxIcon name="document-filter" class="size-6 shrink-0 text-accent" />
<p class="text-sm leading-8 text-ink/80">
{{ standards.note }}
</p>
@@ -37,7 +37,7 @@ const standards = computed(() => useSiteStore().sections.standards);
href="#contact"
class="group mt-8 inline-flex items-center gap-2.5 rounded-full border border-line px-6 py-3.5 text-sm font-bold transition-colors hover:border-accent hover:text-accent"
>
<ShieldCheck class="size-4 text-accent" />
<VuesaxIcon name="shield-tick" class="size-4 text-accent" />
{{ standards.ctaLabel }}
</a>
</Reveal>
+4 -4
View File
@@ -11,18 +11,18 @@ const stats = computed(() => useSiteStore().sections.stats.items);
<div
v-for="(s, i) in stats"
:key="s.label"
class="group relative bg-bg p-8 transition-colors duration-500 hover:bg-panel md:p-10"
class="group relative bg-bg p-4 sm:p-8 transition-colors duration-500 hover:bg-panel md:p-10"
>
<span
class="absolute end-5 top-5 font-mono text-[10px] tracking-[0.25em] text-muted/50 transition-colors duration-500 group-hover:text-accent/70"
class="absolute end-3 top-3 sm:end-5 sm:top-5 font-mono text-[9px] sm:text-[10px] tracking-[0.25em] text-muted/50 transition-colors duration-500 group-hover:text-accent/70"
>
{{ s.note }}
</span>
<Reveal :delay="i * 0.08">
<div class="text-4xl font-black md:text-6xl">
<div class="text-2xl sm:text-4xl md:text-6xl font-black">
<CountUp :end="s.end" :suffix="s.suffix" />
</div>
<div class="mt-3 text-sm text-muted">{{ s.label }}</div>
<div class="mt-1.5 sm:mt-3 text-xs sm:text-sm text-muted">{{ s.label }}</div>
</Reveal>
<span
class="absolute bottom-0 start-0 h-0.5 w-0 bg-accent transition-all duration-500 group-hover:w-full"
@@ -1,26 +1,26 @@
<script setup lang="ts">
import { ShieldCheck } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
const items = computed(() => useSiteStore().sections.tape.items);
</script>
<template>
<section aria-hidden="true" class="pointer-events-none relative z-20 overflow-hidden py-10">
<section aria-hidden="true" class="pointer-events-none relative z-20 overflow-hidden py-6 sm:py-10">
<div class="-rotate-[1.6deg] scale-[1.04]">
<div class="hazard h-2.5" />
<div dir="ltr" class="overflow-hidden bg-hazard py-3.5 text-bg">
<div class="hazard h-2 sm:h-2.5" />
<div dir="ltr" class="overflow-hidden bg-hazard py-2.5 sm:py-3.5 text-bg">
<div class="animate-marquee flex w-max">
<span
v-for="(item, i) in [...items, ...items]"
:key="i"
class="flex items-center gap-7 px-7 text-sm font-extrabold whitespace-nowrap"
class="flex items-center gap-5 sm:gap-7 px-5 sm:px-7 text-xs sm:text-sm font-extrabold whitespace-nowrap"
>
{{ item }}
<ShieldCheck class="size-4 shrink-0" :stroke-width="2.5" />
<VuesaxIcon name="shield-tick" class="size-3.5 sm:size-4 shrink-0" />
</span>
</div>
</div>
<div class="hazard h-2.5" />
<div class="hazard h-2 sm:h-2.5" />
</div>
</section>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { Quote } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const testimonials = computed(() => useSiteStore().sections.testimonials);
@@ -36,7 +36,7 @@ onBeforeUnmount(() => clearInterval(t));
<span
class="flex size-16 items-center justify-center rounded-full border border-accent/30 bg-accent/10"
>
<Quote class="size-7 text-accent" />
<VuesaxIcon name="quote-up" class="size-7 text-accent" />
</span>
<AnimatePresence mode="wait">
+3 -106
View File
@@ -1,112 +1,9 @@
<script setup lang="ts">
import type { IconName } from "@imen/shared";
import {
Award,
BadgeCheck,
Building2,
ClipboardCheck,
Clock,
Construction,
Ear,
Factory,
FileCheck,
FileSearch,
Flame,
Footprints,
Fuel,
Gauge,
Glasses,
Globe,
GraduationCap,
Hand,
Handshake,
HardHat,
Headset,
HeartPulse,
Leaf,
Lock,
Mail,
MapPin,
Medal,
Microscope,
Package,
PackageCheck,
Phone,
Pickaxe,
ShieldAlert,
ShieldCheck,
ShieldPlus,
Shirt,
Siren,
Star,
Target,
ThumbsUp,
TrafficCone,
TriangleAlert,
Truck,
Users,
Warehouse,
Wind,
Wrench,
Zap,
} from "lucide-vue-next";
import type { Component } from "vue";
import VuesaxIcon from "./VuesaxIcon.vue";
/** Explicit registry (matches ICON_NAMES in @imen/shared) so only these icons are bundled */
const ICONS: Record<IconName, Component> = {
ShieldCheck,
ShieldPlus,
ShieldAlert,
BadgeCheck,
Award,
Medal,
PackageCheck,
Package,
Truck,
Warehouse,
Globe,
Users,
Handshake,
Headset,
ClipboardCheck,
FileCheck,
FileSearch,
GraduationCap,
Gauge,
Microscope,
Wrench,
HardHat,
Shirt,
Footprints,
Hand,
Glasses,
Ear,
Wind,
TriangleAlert,
Siren,
TrafficCone,
Construction,
Factory,
Building2,
Fuel,
Pickaxe,
Flame,
Zap,
Leaf,
HeartPulse,
Clock,
Phone,
Mail,
MapPin,
Star,
ThumbsUp,
Target,
Lock,
};
defineProps<{ name: string }>();
defineProps<{ name: string; size?: number | string }>();
</script>
<template>
<component :is="ICONS[name as IconName] ?? ShieldCheck" />
<VuesaxIcon :name="name" :size="size" />
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ImageOff } from "lucide-vue-next";
import VuesaxIcon from "~/components/ui/VuesaxIcon.vue";
withDefaults(
defineProps<{
@@ -17,7 +17,7 @@ withDefaults(
<div
:class="`flex flex-col items-center justify-center border border-dashed border-line bg-panel ${aspectClass}`"
>
<ImageOff class="size-10 text-muted/30" />
<VuesaxIcon name="gallery-slash" class="size-10 text-muted/30" />
<span class="mt-2 font-mono text-[10px] tracking-wider text-muted/40">{{ label }}</span>
</div>
</template>
+2 -2
View File
@@ -34,7 +34,7 @@ withDefaults(
<component
:is="`h${level}`"
:id="headingId"
class="mt-5 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]"
class="mt-3.5 sm:mt-5 text-2xl sm:text-3xl md:text-5xl font-black leading-tight sm:leading-[1.25] md:leading-[1.2]"
>
<slot name="title">
<Lines v-if="title" :text="title" />
@@ -46,7 +46,7 @@ withDefaults(
</component>
</Reveal>
<Reveal v-if="desc" :delay="0.16">
<p :class="`mt-5 max-w-xl leading-8 text-muted ${center ? 'mx-auto' : ''}`">
<p :class="`mt-3 sm:mt-5 max-w-xl text-xs sm:text-base leading-6 sm:leading-8 text-muted ${center ? 'mx-auto' : ''}`">
{{ desc }}
</p>
</Reveal>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from "vue";
import { resolveVuesaxIcon } from "@imen/shared";
import icons from "~/icons/vuesax-linear.json";
const props = defineProps<{
/** Short name (`add`), alias (`ShieldCheck`, `Phone`), or full key (`vuesax-linear:add`) */
name: string;
size?: number | string;
}>();
const pack = icons as Record<string, string>;
const svgHtml = computed(() => {
const resolved = resolveVuesaxIcon(props.name || "");
const key = resolved.includes(":") ? resolved : `vuesax-linear:${resolved}`;
const raw = pack[key] || pack[`vuesax-linear:${resolved.toLowerCase()}`] || pack["vuesax-linear:shield-tick"];
if (!raw) return "";
return raw
.replace(/\swidth="24"/, "")
.replace(/\sheight="24"/, "")
.replace(/stroke="#[A-Fa-f0-9]{3,8}"/g, 'stroke="currentColor"')
.replace(/fill="(?!none)#[A-Fa-f0-9]{3,8}"/g, 'fill="currentColor"');
});
const box = computed(() => {
if (!props.size) return undefined;
return typeof props.size === "number" ? `${props.size}px` : props.size;
});
</script>
<template>
<span
class="anticon vx-icon"
:style="box ? { width: box, height: box } : undefined"
:aria-hidden="true"
v-html="svgHtml"
/>
</template>
<style scoped>
.vx-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
line-height: 0;
color: inherit;
vertical-align: -0.15em;
}
.vx-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
</style>