Author SHA1 Message Date
S. EsmaeiliandCursor 31076174e2 feat: switch to IRANSans FaNum fonts, add product images, rebuild out
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 17:01:47 +03:30
Montech 0eb23b40e5 fix add out on git 2026-10-07 15:16:36 +03:30
s.esmaeiliandClaude Opus 4.6 bb7f1e3378 feat: update Dockerfile, add category pages, fonts and product images
Rebuild Docker setup with simplified nginx-only Dockerfile (build runs
on host). Add category detail pages, IRANSans font files, product
images, and update all landing sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 00:45:51 +03:30
S. EsmaeiliandClaude Opus 4.6 e31ef5a21f feat: migrate landing from Next.js to Nuxt 4 (Vue 3)
Full 1:1 migration preserving all visuals, animations and behavior:
- Nuxt 4 + Vue 3 (script setup, TypeScript strict) with SSG output
- Tailwind CSS v4 with @tailwindcss/vite
- Ant Design Vue 4 (dark theme, RTL, Persian locale) for design system
- Pinia for state management (ui + contact stores)
- motion-v (framer-motion port) for all animations
- lucide-vue-next for icons
- SEO improvements: og:image, canonical fix, real 404 page, JSON-LD services
- JS bundle reduced from 302KB to 239KB gzip
- Docker/nginx deployment preserved (out/ -> nginx)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 16:28:05 +03:30
387 changed files with 13740 additions and 3540 deletions
+3 -5
View File
@@ -1,11 +1,9 @@
node_modules
.next
.nuxt
.output
.git
.gitignore
src
public
_old_vite_backup
*.md
_dev.log
.env*
npm-debug.log*
logo.png
+7 -7
View File
@@ -1,10 +1,12 @@
# dependencies
node_modules/
# next.js
.next/
# out/ عمداً در git نگه داشته می‌شود (بیلد محلی → دیپلوی با Docker)
next-env.d.ts
# nuxt
.nuxt/
.output/
.data/
dist
# misc
.DS_Store
@@ -14,7 +16,5 @@ next-env.d.ts
npm-debug.log*
# local env files
.env
.env*.local
# old vite backup (remove once migration is verified and no longer needed)
_old_vite_backup/
+3 -3
View File
@@ -1,8 +1,8 @@
FROM docker.arvancloud.ir/nginx:latest
# Static export is built on the host (`npm run build` → ./out)
# This image only serves the pre-built files via nginx.
FROM docker.arvancloud.ir/nginx:alpine
# خروجی static export (npm run build → پوشه out)
COPY out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+82 -71
View File
@@ -1,35 +1,64 @@
# آریا ایمن — لندینگ (Next.js)
# آریا ایمن — لندینگ (Nuxt)
لندینگ شرکت آریا ایمن، مهاجرت‌یافته از Vite/React به Next.js (App Router) با خروجی کاملاً استاتیک، بهینه برای سئو و سرعت.
لندینگ شرکت آریا ایمن، مهاجرت‌یافته از Next.js به **Nuxt 4 (Vue 3)** با خروجی کاملاً استاتیک (SSG)، بهینه برای سئو و سرعت.
ظاهر و رفتار صفحه دقیقاً همان نسخه Next.js است.
## نصب
| لایه | ابزار |
|---|---|
| فریم‌ورک | Nuxt 4 + Vue 3 (`<script setup>`, TypeScript strict) |
| استایل | Tailwind CSS v4 (`@tailwindcss/vite`) — توکن‌ها در `app/assets/css/main.css` |
| Design system | Ant Design Vue 4 — تم تیره با پالت پروژه در `app/design/theme.ts`، RTL و لوکال فارسی |
| State management | Pinia (`app/stores/`) |
| انیمیشن | motion-v (پورت Motion/framer-motion برای Vue) |
| آیکن | lucide-vue-next |
## نصب و اجرا
```bash
npm install
npm run dev # http://localhost:3000
npm run typecheck # بررسی TypeScript
```
## اجرا در حالت توسعه
```bash
npm run dev
```
سپس آدرس `http://localhost:3000` را باز کنید.
## Build (تولید نسخه نهایی استاتیک)
```bash
npm run build
npm run build # = nuxt generate
```
خروجی در پوشه `out/` ساخته می‌شود — مجموعه‌ای از فایل‌های HTML/CSS/JS کاملاً استاتیک که نیازی به سرور Node ندارند.
خروجی در پوشه `out/` ساخته می‌شود — فایل‌های HTML/CSS/JS کاملاً استاتیک که نیازی به سرور Node ندارند
(`index.html`، `404.html`، `robots.txt`، `sitemap.xml`، `manifest.webmanifest`، `_nuxt/`).
برای پیش‌نمایش محلی خروجی build:
پیش‌نمایش محلی خروجی:
```bash
npx serve out
npm run preview # = npx serve out
```
## ساختار پروژه
```
app/
app.vue ConfigProvider (antd) + سئوی سراسری + JSON-LD سازمان/وب‌سایت
error.vue صفحه ۴۰۴ / خطا (noindex) — در build به‌صورت out/404.html پیش‌رندر می‌شود
pages/index.vue صفحه اصلی (AppShell → ۱۰ سکشن + Footer)، canonical
components/layout/ AppShell, Header, Footer, Preloader, Cursor, BackToTop
components/sections/ Hero, TapeMarquee, Stats, CountUp, Products, About, Services, Standards, Industries, Testimonials, Contact
components/ui/ Logo, Reveal, SectionHead
composables/ useSiteSeo, useJsonLd, useTehranClock, useScrolledPast
stores/ ui (Preloader/منوی موبایل)، contact (فرم تماس)
design/theme.ts توکن‌های Ant Design (هم‌گام با @theme در main.css)
assets/css/ main.css (Tailwind + فونت‌ها)، antd-overrides.css
plugins/antd-style.ts استخراج CSS-in-JS آنت‌دیزاین در SSR (بدون FOUC)
shared/ config/site.ts و seo/schema.ts (مشترک بین app و server)
server/routes/ robots.txt، sitemap.xml، manifest.webmanifest
public/ فونت‌ها، تصاویر، favicon.svg
```
### نکات Ant Design + Tailwind
- استایل‌های Tailwind v4 داخل cascade layer هستند و CSS-in-JS آنت‌دیزاین بیرون از layer؛ بنابراین روی کامپوننت‌های antd کلاس ظاهری Tailwind نگذارید. ظاهر کامپوننت‌های antd از توکن‌ها (`app/design/theme.ts`) و `app/assets/css/antd-overrides.css` می‌آید.
- پالت رنگ در دو جا تعریف شده (`@theme` در `main.css` برای Tailwind و `palette` در `theme.ts` برای antd)؛ هنگام تغییر، هر دو را هم‌گام کنید.
## دیپلوی پروداکشن با Docker
بیلد روی ماشین توسعه انجام می‌شود؛ خروجی `out/` داخل گیت commit می‌شود و روی سرور فقط image ساخته و اجرا می‌شود (بدون `npm install` / `npm run build` در سرور).
@@ -61,78 +90,60 @@ docker build -t aria-imen-landing .
docker run -d --name aria-imen-landing -p 8080:80 --restart unless-stopped aria-imen-landing
```
تنظیمات Nginx داخل image مطابق export استاتیک Next.js است (`try_files`، کش `/_next/static/`، صفحه `404.html`).
تنظیمات Nginx داخل image مطابق خروجی استاتیک Nuxt است (`try_files`، کش immutable برای `/_nuxt/`، صفحه `404.html`).
## آپلود روی هاست (Static Hosting)
این پروژه با `output: 'export'` پیکربندی شده، یعنی:
1. `npm run build` را اجرا کنید.
2. کل محتوای پوشه `out/` را (نه خود پوشه، بلکه فایل‌های داخلش) در ریشه هاست (public_html، یا مشابه آن در cPanel/Nginx) آپلود کنید.
3. سایت بدون نیاز به Node.js یا هیچ سرور اختصاصی کار می‌کند.
### نکات مهم هاست استاتیک (cPanel / Nginx / هاست ایرانی)
- **ریدایرکت‌ها و هدرها** در `next.config.ts` روی static export اجرا نمی‌شوند. اگر بعداً نیاز به ریدایرکت ۳۰۱ داشتید (مثلاً از `/index.html`)، باید در سطح هاست تنظیم شود:
**Apache (.htaccess):**
```apache
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
</IfModule>
ErrorDocument 404 /404.html
```
**Apache (.htaccess):**
```apache
Redirect 301 /index.html /
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
</IfModule>
ErrorDocument 404 /404.html
```
**Nginx:**
```nginx
location /_nuxt/ { expires 1y; add_header Cache-Control "public, immutable"; }
location / { try_files $uri $uri.html $uri/index.html =404; }
error_page 404 /404.html;
gzip on; gzip_types text/css application/javascript application/json image/svg+xml;
```
**Nginx:**
```nginx
location /_next/static/ { expires 1y; add_header Cache-Control "public, immutable"; }
location / { try_files $uri $uri.html $uri/index.html =404; }
error_page 404 /404.html;
gzip on; gzip_types text/css application/javascript image/svg+xml;
```
ریدایرکت `location = /index.html { return 301 /; }` را نگذارید؛ با `index index.html` حلقهٔ ۳۰۱ روی `/` می‌سازد.
ریدایرکت `location = /index.html { return 301 /; }` را نگذارید؛ با `index index.html` حلقهٔ ۳۰۱ روی `/` می‌سازد.
## پیش از دیپلوی واقعی — کارهایی که باید انجام دهید
این پروژه فعلاً با داده‌های تستی/placeholder برای موارد زیر ساخته شده و **باید قبل از انتشار واقعی بررسی و اصلاح شوند**:
1. **دامنه واقعی**: در فایل [`src/config/site.ts`](src/config/site.ts) مقدار `url: "https://ariaiman.ir"` باید با دامنه نهایی واقعی جایگزین شود (این مقدار روی canonical، Open Graph، sitemap و JSON-LD اثر مستقیم دارد).
1. **دامنه واقعی**: در [`shared/config/site.ts`](shared/config/site.ts) مقدار `url: "https://ariaiman.ir"` را با دامنه نهایی جایگزین کنید (روی canonical، Open Graph، sitemap، robots و JSON-LD اثر مستقیم دارد).
2. **لوگوی رسمی**: در همان فایل، `logo` فعلاً به تصویر hero اشاره می‌کند؛ یک فایل PNG/SVG لوگوی واقعی جایگزین شود.
3. **آیکون‌های PWA**: `src/app/manifest.ts` فعلاً بدون آیکون است. دو فایل `icon-192.png` و `icon-512.png` در `public/` قرار داده و در manifest معرفی شوند.
4. **تصویر Open Graph**: یک فایل `opengraph-image.png` با ابعاد ۱۲۰۰×۶۳۰ در `src/app/` قرار دهید (Next.js آن را به‌صورت خودکار شناسایی می‌کند).
5. **شبکه‌های اجتماعی**: در `src/config/site.ts`، آرایه `socials` خالی است. لینک‌های واقعی اینستاگرام/تلگرام/لینکدین را اضافه کنید تا در Schema.org (`sameAs`) درج شود.
6. **فرم تماس**: فرم بخش «تماس» فعلاً فقط یک شبیه‌سازی محلی است (بدون ارسال واقعی به سرور یا سرویس ایمیل) — دقیقاً همان رفتاری که در نسخه قبلی هم وجود داشت. برای فعال‌سازی واقعی باید به یک سرویس فرم (مثل فرم‌اسپری، ایمیل، یا API اختصاصی) متصل شود.
7. **npm audit**: یک آسیب‌پذیری «high» در نسخه postcss داخلی Next.js 15.5 گزارش می‌شود (`npm audit`). این مربوط به مرحله build/dev است، نه کد سمت کاربر نهایی. رفع کامل آن نیازمند ارتقا به Next.js 16 (breaking change) است — در صورت تمایل به‌روزرسانی جداگانه انجام شود.
3. **تصویر Open Graph**: `ogImage` فعلاً `hero.jpg` است؛ یک تصویر اختصاصی ۱۲۰۰×۶۳۰ در `public/` قرار دهید و مسیر/ابعاد را در `site.ts` به‌روز کنید.
4. **آیکون‌های PWA**: manifest فعلاً از `favicon.svg` استفاده می‌کند؛ برای سازگاری کامل، `icon-192.png`، `icon-512.png` و `apple-touch-icon.png` اضافه شوند.
5. **شبکه‌های اجتماعی**: آرایه `socials` در `site.ts` خالی است؛ لینک‌های واقعی را اضافه کنید تا در Schema.org (`sameAs`) درج شوند.
6. **فرم تماس**: اکشن `submit` در [`app/stores/contact.ts`](app/stores/contact.ts) فعلاً فقط ارسال را شبیه‌سازی می‌کند (مثل نسخه قبلی)؛ برای فعال‌سازی واقعی به API/سرویس فرم متصل شود.
## چک‌لیست سئوی پیاده‌سازی‌شده
## چک‌لیست سئو
| مورد | وضعیت | فایل |
|---|---|---|
| یک H1 با کلمه کلیدی اصلی | ✅ | `src/components/sections/Hero.tsx` |
| سلسله‌مراتب H2/H3 منطقی، بدون پرش سطح | ✅ | `src/components/ui/SectionHead.tsx` و همه سکشن‌ها |
| عنوان و توضیحات با طول مناسب | ✅ | `src/config/site.ts` |
| Canonical، metadataBase، Open Graph، Twitter Card | ✅ | `src/app/layout.tsx` |
| `lang="fa"` و `dir="rtl"` | ✅ | `src/app/layout.tsx` |
| JSON-LD (LocalBusiness + WebSite) | ✅ | `src/lib/seo/schema.ts`, `src/components/seo/JsonLd.tsx` |
| `sitemap.xml` | ✅ | `src/app/sitemap.ts` |
| `robots.txt` | ✅ | `src/app/robots.ts` |
| `manifest.webmanifest` | ✅ (بدون آیکون — نیاز به تکمیل) | `src/app/manifest.ts` |
| صفحه ۴۰۴ واقعی (بدون ریدایرکت به خانه) | ✅ | `src/app/not-found.tsx` |
| alt توصیفی روی همه تصاویر محتوایی | ✅ | همه کامپوننت‌های سکشن |
| تمام متن در HTML بدون نیاز به اجرای JS | ✅ | تأیید شده با بررسی خروجی build |
| لینک‌های داخلی سالم (`#anchor`) | ✅ | بدون تغییر نسبت به نسخه اصلی |
| فونت خودمیزبان (self-hosted) بدون CDN خارجی | ✅ | `public/fonts/`, `src/app/layout.tsx` |
| تصاویر بدون وابستگی به CDN خارجی | ✅ | تصاویر Industries از pexels دانلود و داخلی شدند |
## معماری فنی مهاجرت
- **Server Components برای محتوا**: تمام بخش‌های بدون تعامل (Hero, About, Standards, TapeMarquee, Footer) به‌صورت Server Component هستند و در HTML build نهایی کامل قابل مشاهده‌اند.
- **Client Components فقط برای تعامل**: منو موبایل، فرم تماس، اسلایدر نظرات، پنل‌های تعاملی محصولات/خدمات، شمارنده آمار، کرسر سفارشی و Preloader، هرکدام جداگانه `'use client'` هستند تا حجم JS اولیه صفحه کمینه بماند.
- **بدون بک‌اند**: هیچ `app/api`، دیتابیس یا server action اضافه نشده؛ طبق درخواست پروژه.
- **انیمیشن ورودی Hero با CSS خالص**: برخلاف نسخه اصلی (که H1 تا پایان انیمیشن Preloader با `opacity:0` مخفی بود)، اکنون محتوای Hero همیشه در HTML اولیه قابل مشاهده است و فقط با CSS keyframes (بدون وابستگی به اجرای JS) وارد صفحه می‌شود — این تغییر برای رفع مشکل LCP/SEO با تأیید شما انجام شد.
| یک H1 با کلمه کلیدی اصلی | ✅ | `app/components/sections/Hero.vue` |
| سلسله‌مراتب H2/H3 منطقی، بدون پرش سطح | ✅ | `app/components/ui/SectionHead.vue` و همه سکشن‌ها |
| عنوان، title template و توضیحات | ✅ | `shared/config/site.ts`, `app/composables/useSiteSeo.ts` |
| Canonical یکسان با sitemap (`https://ariaiman.ir/`) | ✅ | `app/pages/index.vue` |
| Open Graph و Twitter Card **همراه با تصویر** (og:image/twitter:image) | ✅ | `app/composables/useSiteSeo.ts` |
| `lang="fa"` و `dir="rtl"` | ✅ | `nuxt.config.ts` |
| JSON-LD: LocalBusiness (+ ساعات کاری، سال تأسیس، منطقه خدمات)، WebSite، Service ×۴ | ✅ | `shared/seo/schema.ts`, `app/composables/useJsonLd.ts` |
| `sitemap.xml` / `robots.txt` / `manifest.webmanifest` (با آیکون) | ✅ | `server/routes/` |
| صفحه ۴۰۴ واقعی پیش‌رندرشده، `noindex` و بدون canonical | ✅ | `app/error.vue`, `nuxt.config.ts` |
| preload تصویر hero و فونت‌ها | ✅ | `app/pages/index.vue`, `nuxt.config.ts` |
| alt توصیفی روی همه تصاویر محتوایی | ✅ | همه سکشن‌ها |
| تمام متن در HTML بدون نیاز به اجرای JS | ✅ | خروجی `out/index.html` |
| فونت و تصاویر خودمیزبان (بدون CDN) | ✅ | `public/` |
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { ConfigProvider, StyleProvider } from "ant-design-vue";
import faIR from "ant-design-vue/es/locale/fa_IR";
import { antdTheme } from "~/design/theme";
import { organizationSchema, websiteSchema } from "#shared/seo/schema";
const { $antdCache } = useNuxtApp();
useSiteSeo();
useJsonLd([organizationSchema(), websiteSchema()]);
</script>
<template>
<StyleProvider :cache="$antdCache">
<ConfigProvider :theme="antdTheme" direction="rtl" :locale="faIR">
<NuxtPage />
</ConfigProvider>
</StyleProvider>
</template>
+118
View File
@@ -0,0 +1,118 @@
/*
* Ant Design Vue ↔ project look.
* antd's CSS-in-JS is unlayered while Tailwind v4 lives in cascade layers, so Tailwind classes
* can't restyle antd elements. These unlayered rules pin antd form controls to the exact
* pre-migration styling (same values as the old Tailwind `inputCls` / submit button).
*/
/* ---- form shell ---- */
.contact-form.ant-form {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
/* antd resets items and their grid wrappers to 14px/1.5714; inherit like the original markup did
(the control line box decides the gap under the inline-block textarea) */
.contact-form .ant-form-item,
.contact-form .ant-form-item .ant-row,
.contact-form .ant-form-item .ant-col {
margin-bottom: 0;
font-size: inherit;
line-height: inherit;
}
.contact-form .ant-form-item .ant-form-item-label {
padding: 0 0 8px; /* mb-2 */
line-height: 16px;
}
.contact-form .ant-form-item-label > label {
width: 100%; /* original labels were full-width blocks */
height: auto;
font-size: 12px; /* text-xs */
line-height: 16px;
color: var(--color-muted);
}
.contact-form .ant-form-item-explain {
font-size: 12px;
line-height: 20px;
padding-top: 4px;
}
/* ---- inputs (= w-full rounded-lg border border-line bg-bg px-4 py-3.5 text-sm text-ink) ---- */
.contact-form .ant-input {
width: 100%;
border-radius: 8px;
border: 1px solid var(--color-line);
background-color: var(--color-bg);
padding: 14px 16px;
font-family: inherit;
font-size: 14px;
line-height: 20px;
color: var(--color-ink);
box-shadow: none;
transition-property: color, background-color, border-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.contact-form .ant-input::placeholder {
color: color-mix(in oklab, var(--color-muted) 50%, transparent);
}
.contact-form .ant-input:hover {
border-color: var(--color-line);
}
.contact-form .ant-input:focus,
.contact-form .ant-input-focused {
border-color: var(--color-accent);
outline: none;
box-shadow: none;
}
/* antd's .ant-input-rtl forces direction:rtl; honour an explicit dir="ltr" (phone field) */
.contact-form .ant-input[dir="ltr"] {
direction: ltr;
text-align: left;
}
.contact-form textarea.ant-input {
resize: none;
min-height: 0;
vertical-align: baseline; /* native inline-block textarea alignment (antd sets bottom) */
}
/* ---- submit (= rounded-full bg-accent py-4 font-bold text-bg, hover:bg-accent-soft + glow) ---- */
.contact-form .ant-btn.contact-submit {
display: flex;
width: 100%;
height: auto;
align-items: center;
justify-content: center;
gap: 10px;
border: 0;
border-radius: calc(infinity * 1px); /* = tailwind rounded-full */
background-color: var(--color-accent);
padding: 16px 0;
font-family: inherit;
font-size: 16px;
line-height: 24px;
font-weight: 700;
color: var(--color-bg);
box-shadow: none;
cursor: default;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.contact-form .ant-btn.contact-submit > span {
display: inline;
}
@media (hover: hover) {
.contact-form .ant-btn.contact-submit:hover {
background-color: var(--color-accent-soft);
color: var(--color-bg);
box-shadow: 0 8px 45px rgba(255, 90, 0, 0.45);
}
}
.contact-form .ant-btn.contact-submit:disabled {
opacity: 0.6;
border: 0;
}
/* no click-wave ring (wasn't part of the original button) */
.contact-form .ant-btn.contact-submit::after,
.contact-form .ant-wave {
display: none;
}
+58 -11
View File
@@ -1,18 +1,65 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-vazirmatn), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; /* added for migration: next/font variable */
--font-mono: var(--font-ibm-plex-mono), "Vazirmatn", ui-monospace, monospace; /* added for migration: next/font variable */
/* ---- local fonts ---- */
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum_UltraLight.woff2") format("woff2");
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum_Light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum_Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum_Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum_Black.woff2") format("woff2");
font-weight: 900;
font-style: normal;
font-display: swap;
}
:root {
--font-iransans: "IRANSans", sans-serif;
}
--color-bg: #0a0a0c;
--color-panel: #121216;
--color-panel-2: #17171c;
@theme {
--font-sans: var(--font-iransans);
--font-mono: var(--font-iransans);
--color-bg: #0b1224;
--color-panel: #101a32;
--color-panel-2: #152040;
--color-ink: #edede6;
--color-muted: #8e8e96;
--color-muted: #8e9ab0;
--color-accent: #ff5a00;
--color-accent-soft: #ff7a2a;
--color-hazard: #ffb800;
--color-line: #232329;
--color-line: #1e2d4d;
--animate-marquee: marquee 32s linear infinite;
--animate-spin-slow: spin-slow 18s linear infinite;
@@ -109,7 +156,7 @@ body {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: #2c2c33;
background: #253558;
border-radius: 8px;
border: 2px solid var(--color-bg);
}
@@ -122,7 +169,7 @@ body {
background-image: repeating-linear-gradient(
-45deg,
#ffb800 0 14px,
#0a0a0c 14px 28px
#0b1224 14px 28px
);
}
@@ -161,6 +208,6 @@ body {
background-image: repeating-linear-gradient(
45deg,
#ffb800 0 10px,
#0a0a0c 10px 20px
#0b1224 10px 20px
);
}
+24
View File
@@ -0,0 +1,24 @@
<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>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { ArrowUp } from "lucide-vue-next";
const show = useScrolledPast(700);
</script>
<template>
<AnimatePresence>
<motion.a
v-if="show"
href="#home"
aria-label="بازگشت به بالا"
:initial="{ opacity: 0, y: 24 }"
:animate="{ opacity: 1, y: 0 }"
: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" />
</motion.a>
</AnimatePresence>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { motion, useMotionValue, useSpring } from "motion-v";
const x = useMotionValue(-100);
const y = useMotionValue(-100);
const sx = useSpring(x, { stiffness: 550, damping: 45, mass: 0.4 });
const sy = useSpring(y, { stiffness: 550, damping: 45, mass: 0.4 });
const hover = ref(false);
const visible = ref(false);
const move = (e: MouseEvent) => {
x.set(e.clientX);
y.set(e.clientY);
visible.value = true;
};
const over = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
hover.value = !!t?.closest?.(
"a, button, input, textarea, select, label, [data-hover]",
);
};
onMounted(() => {
window.addEventListener("mousemove", move, { passive: true });
window.addEventListener("mouseover", over, { passive: true });
});
onBeforeUnmount(() => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseover", over);
});
</script>
<template>
<div class="pointer-events-none fixed inset-0 z-[120] hidden [@media(pointer:fine)]:block">
<motion.div :style="{ x: sx, y: sy }" class="absolute -left-4 -top-4 size-8">
<motion.div
:animate="{
scale: hover ? 1.9 : 1,
opacity: visible ? 1 : 0,
rotate: hover ? 45 : 0,
}"
:transition="{ type: 'spring', stiffness: 320, damping: 24 }"
class="size-8 rounded-md border-2 border-accent/80"
/>
<div
:class="`absolute inset-0 m-auto size-1 rounded-full bg-accent transition-opacity duration-300 ${
visible ? 'opacity-100' : 'opacity-0'
}`"
/>
</motion.div>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { AtSign, Globe, Phone, Send } from "lucide-vue-next";
import { CATEGORIES } from "#shared/data/categories";
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 PRODUCT_LINKS = CATEGORIES.map((c) => ({
label: c.name,
href: `/categories/${c.slug}`,
}));
const SOCIALS = [
{ icon: Globe, label: "وب‌سایت" },
{ icon: Send, label: "تلگرام" },
{ icon: AtSign, label: "ایمیل" },
{ icon: Phone, label: "تماس" },
];
</script>
<template>
<footer class="relative overflow-hidden border-t border-line">
<div class="hazard h-2.5" />
<div class="mx-auto max-w-[1480px] px-5 md:px-8">
<div class="grid gap-12 py-16 md:grid-cols-12 md:py-20">
<!-- brand -->
<div class="md:col-span-5">
<Logo />
<p class="mt-6 max-w-sm text-sm leading-8 text-muted">
تأمین‌کننده تخصصی تجهیزات حفاظت فردی و راهکارهای ایمنی صنعتی؛
همراه سازمان‌ها از سال ۱۳۸۶ در مسیر صفر شدن حوادث کاری.
</p>
<div class="mt-7 flex gap-3">
<a
v-for="s in SOCIALS"
:key="s.label"
href="/#contact"
:aria-label="s.label"
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" />
</a>
</div>
</div>
<!-- quick links -->
<div class="md:col-span-2">
<div class="font-mono text-xs tracking-[0.25em] text-accent">// دسترسی</div>
<ul class="mt-5 space-y-3">
<li v-for="l in QUICK_LINKS" :key="l.href">
<a :href="l.href" class="text-sm text-muted transition-colors hover:text-ink">
{{ l.label }}
</a>
</li>
</ul>
</div>
<!-- products -->
<div class="md:col-span-2">
<div class="font-mono text-xs tracking-[0.25em] text-accent">// محصولات</div>
<ul class="mt-5 space-y-3">
<li v-for="p in PRODUCT_LINKS" :key="p.href">
<NuxtLink :to="p.href" class="text-sm text-muted transition-colors hover:text-ink">
{{ p.label }}
</NuxtLink>
</li>
</ul>
</div>
<!-- contact -->
<div class="md:col-span-3">
<div class="font-mono text-xs tracking-[0.25em] text-accent">// ارتباط</div>
<address class="mt-5 space-y-3 text-sm text-muted not-italic">
<p class="leading-7">
تهران، خیابان کارگر شمالی،
<br />
خیابان فر، کوچه دوم، پلاک ۱۲
</p>
<p class="font-mono tracking-wider" dir="ltr">۰۲۱ - ۹۱ ۰۰ ۸۸ ۰۰</p>
<p class="font-mono tracking-wider" dir="ltr">info@imenpishgaman.ir</p>
<p class="pt-2 text-xs leading-6">
شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰
<br />
پشتیبانی اضطراری: ۲۴/۷
</p>
</address>
</div>
</div>
<!-- giant wordmark -->
<div aria-hidden="true" class="pointer-events-none select-none pb-6">
<div class="text-outline text-center text-[17vw] font-black leading-[0.85] md:text-[11vw]">
ایمن پیشگامان
</div>
</div>
<div
class="flex flex-wrap items-center justify-between gap-4 border-t border-line py-6 text-xs text-muted"
>
<div>© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.</div>
<div class="font-mono text-[10px] tracking-[0.25em]">
ISO 9001:2015 — CE CERTIFIED PPE SUPPLIER
</div>
</div>
</div>
</footer>
</template>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { Menu, Phone, X } from "lucide-vue-next";
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 ui = useUiStore();
const scrolled = useScrolledPast(40);
const { time, shift } = useTehranClock();
watch(
() => ui.menuOpen,
(open) => {
document.body.style.overflow = open ? "hidden" : "";
},
);
onBeforeUnmount(() => {
document.body.style.overflow = "";
});
</script>
<template>
<motion.header
:initial="{ y: -90, opacity: 0 }"
:animate="ui.ready ? { y: 0, opacity: 1 } : {}"
:transition="{ duration: 0.9, delay: 0.15, ease: EASE }"
:class="`fixed inset-x-0 top-0 z-[80] transition-all duration-500 ${
scrolled ? 'border-b border-line bg-bg/85 py-3 backdrop-blur-xl' : 'py-5'
}`"
>
<div class="mx-auto flex max-w-[1480px] items-center justify-between px-5 md:px-8">
<Logo />
<nav class="hidden items-center gap-7 lg:flex">
<a
v-for="l in LINKS"
:key="l.href"
:href="l.href"
class="group relative py-2 text-sm text-ink/75 transition-colors hover:text-ink"
>
{{ l.label }}
<span
class="absolute bottom-0 start-0 h-0.5 w-full origin-right scale-x-0 bg-accent transition-transform duration-300 group-hover:scale-x-100"
/>
</a>
</nav>
<div class="flex items-center gap-3">
<div
class="hidden items-center gap-2.5 rounded-full border border-line px-4 py-2 text-[11px] text-muted xl:flex"
>
<span class="size-1.5 animate-pulse rounded-full bg-emerald-400" />
<span class="font-mono">{{ time }}</span>
<span class="text-muted/50">|</span>
<span>تهران — شیفت {{ shift }}</span>
</div>
<a
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" />
درخواست مشاوره
</a>
<button
aria-label="باز کردن منو"
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" />
</button>
</div>
</div>
</motion.header>
<AnimatePresence>
<motion.div
v-if="ui.menuOpen"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0 }"
:transition="{ duration: 0.35 }"
class="fixed inset-0 z-[110] flex flex-col bg-bg/95 backdrop-blur-2xl"
>
<div class="flex items-center justify-between border-b border-line px-5 py-4 md:px-8">
<Logo />
<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="ui.closeMenu"
>
<X class="size-5" />
</button>
</div>
<nav class="flex flex-1 flex-col justify-center gap-1 overflow-y-auto px-8">
<motion.a
v-for="(l, i) in LINKS"
:key="l.href"
:href="l.href"
:initial="{ opacity: 0, x: 44 }"
:animate="{ opacity: 1, x: 0 }"
:transition="{ delay: 0.08 + i * 0.055, duration: 0.55, ease: EASE }"
class="group flex items-baseline gap-5 border-b border-line/60 py-4"
@click="ui.closeMenu"
>
<span class="font-mono text-xs text-accent">
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
<span class="text-3xl font-black transition-colors group-hover:text-accent">
{{ l.label }}
</span>
</motion.a>
</nav>
<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" />
<span class="font-mono">۰۲۱-۹۱۰۰۸۸۰۰</span>
</div>
<div class="text-xs text-muted">تهران — شیفت {{ shift }}، {{ time }}</div>
</div>
</motion.div>
</AnimatePresence>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { motion } from "motion-v";
import { ShieldCheck } from "lucide-vue-next";
const STEPS = [
"بررسی سیستم‌های ایمنی",
"تأیید استانداردهای بین‌المللی",
"بارگذاری تجهیزات حفاظت فردی",
"محیط کاری آماده است",
];
const emit = defineEmits<{ complete: [] }>();
const progress = ref(0);
let done = false;
let t: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
let v = 0;
t = setInterval(() => {
v += Math.floor(Math.random() * 11) + 7;
if (v >= 100) {
v = 100;
clearInterval(t);
if (!done) {
done = true;
setTimeout(() => emit("complete"), 450);
}
}
progress.value = v;
}, 140);
});
onBeforeUnmount(() => clearInterval(t));
const step = computed(
() =>
STEPS[
Math.min(STEPS.length - 1, Math.floor((progress.value / 100) * STEPS.length))
],
);
</script>
<template>
<motion.div
:exit="{ y: '-100%' }"
:transition="{ duration: 0.85, ease: [0.76, 0, 0.24, 1] }"
class="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-bg"
>
<div class="hazard absolute inset-x-0 top-0 h-3" />
<div class="hazard absolute inset-x-0 bottom-0 h-3" />
<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" />
</span>
<span class="font-mono text-xs tracking-[0.35em] text-muted">
IMEN PISHGAMAN CO.
</span>
</div>
<div class="mt-12 font-mono text-7xl font-semibold tabular-nums md:text-8xl">
{{ progress.toLocaleString("fa-IR") }}<span class="text-accent">٪</span>
</div>
<div class="mt-8 h-[3px] w-64 overflow-hidden rounded-full bg-line md:w-80">
<div
class="h-full bg-accent transition-all duration-200"
:style="{ width: `${progress}%` }"
/>
</div>
<div class="mt-6 flex items-center gap-3 text-xs text-muted">
<span class="size-1.5 animate-pulse rounded-full bg-accent" />
{{ step }}
<span class="animate-blink text-accent">▍</span>
</div>
</motion.div>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { Globe, Headset, Users } from "lucide-vue-next";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const TIMELINE = [
{ year: "۱۳۸۶", text: "تأسیس ایمن پیشگامان در تهران با تمرکز بر تجهیزات حفاظت فردی" },
{ year: "۱۳۹۲", text: "توسعه شبکه توزیع به تمام ۳۱ استان کشور" },
{ year: "۱۳۹۸", text: "راه‌اندازی آزمایشگاه کنترل کیفیت و تست تجهیزات" },
{ year: "۱۴۰۲", text: "افتتاح مرکز تخصصی آموزش و بازرسی HSE" },
];
const FEATURES = [
{ icon: Globe, label: "نماینده رسمی ۴۰+ برند جهانی" },
{ icon: Users, label: "تیم مهندسی و بازرسی HSE" },
{ icon: Headset, label: "پشتیبانی ۲۴/۷ قراردادها" },
];
</script>
<template>
<section
id="about"
aria-labelledby="about-title"
class="relative overflow-hidden border-t border-line py-24 md:py-32"
>
<div class="absolute inset-0 bg-grid-fine opacity-50" />
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<div class="grid items-center gap-14 lg:grid-cols-2">
<!-- ---- image ---- -->
<Reveal class="relative order-2 lg:order-1" :delay="0.1">
<div class="relative overflow-hidden rounded-2xl border border-line">
<img
src="/images/about.jpg"
alt="انبار تجهیزات ایمنی ایمن پیشگامان"
width="800"
height="600"
loading="lazy"
decoding="async"
class="aspect-[4/3] w-full object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-t from-bg/80 via-transparent to-transparent" />
<div
class="absolute bottom-5 start-5 flex items-center gap-4 rounded-xl border border-line bg-bg/80 px-5 py-4 backdrop-blur-md"
>
<span class="font-mono text-3xl font-bold text-accent">۱۸+</span>
<span class="text-xs leading-5 text-muted">
سال حفاظت از
<br />
جان نیروی کار
</span>
</div>
</div>
<div class="hazard absolute -top-3 -end-3 h-6 w-28 rounded-full opacity-90" />
</Reveal>
<!-- ---- text ---- -->
<div class="order-1 lg:order-2">
<SectionHead heading-id="about-title" tag="۰۲ — درباره ما">
<template #title>
ایمنی برای ما شعار نیست؛
<br />
<span class="text-accent">قرارداد است.</span>
</template>
</SectionHead>
<Reveal :delay="0.2">
<p class="mt-6 leading-9 text-muted">
از سال ۱۳۸۶، زمانی که اولین کلاه ایمنی را به یک کارگاه کوچک در
جنوب تهران فروختیم، باور ما همین یک جمله بود: پشت هر تجهیز
ایمنی، یک خانواده هست که منتظر بازگشت عزیزش است. امروز با بیش
از ۸۵۰ سازمان، از پالایشگاه‌ها تا معادن و سایت‌های ساختمانی، در
کنار کسانی هستیم که ایمنی را یک خط قرمز می‌دانند.
</p>
</Reveal>
<div class="mt-10 space-y-1">
<Reveal v-for="(t, i) in TIMELINE" :key="t.year" :delay="0.25 + i * 0.07">
<div
class="group border-s-2 border-accent/30 ps-6 transition-colors duration-300 hover:border-accent"
>
<span class="font-mono text-sm font-bold text-accent">{{ t.year }}</span>
<p class="mt-1 pb-5 text-sm leading-7 text-ink/75">{{ t.text }}</p>
</div>
</Reveal>
</div>
<Reveal :delay="0.5">
<div class="mt-4 flex flex-wrap gap-3">
<span
v-for="f in FEATURES"
:key="f.label"
class="inline-flex items-center gap-2 rounded-full border border-line bg-panel/60 px-4 py-2 text-xs text-ink/80 transition-colors hover:border-accent/50 hover:text-ink"
>
<component :is="f.icon" class="size-4 text-accent" />
{{ f.label }}
</span>
</div>
</Reveal>
</div>
</div>
</div>
</section>
</template>
+190
View File
@@ -0,0 +1,190 @@
<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 Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const INFO = [
{
icon: MapPin,
label: "نشانی",
value: "تهران، خیابان کارگر شمالی، خیابان فر، کوچه دوم، پلاک ۱۲",
},
{ icon: Phone, label: "تلفن تماس", value: "۰۲۱ - ۹۱ ۰۰ ۸۸ ۰۰", ltr: true },
{ icon: Mail, label: "ایمیل", value: "info@imenpishgaman.ir", ltr: true },
{ icon: Clock, label: "ساعات کاری", value: "شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰" },
];
const required = (message: string): Rule[] => [
{ required: true, whitespace: true, message, trigger: "blur" },
];
const rules: Record<string, Rule[]> = {
name: required("لطفاً نام و نام خانوادگی را وارد کنید"),
org: required("لطفاً نام سازمان را وارد کنید"),
phone: required("لطفاً شماره تماس را وارد کنید"),
};
const contact = useContactStore();
</script>
<template>
<section
id="contact"
aria-labelledby="contact-title"
class="relative overflow-hidden border-t border-line py-24 md:py-32"
>
<div class="absolute inset-0 bg-grid-fine opacity-40" />
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<div class="grid gap-14 lg:grid-cols-2">
<!-- ---- info ---- -->
<div>
<SectionHead
heading-id="contact-title"
tag="۰۷ — تماس"
desc="فرم را پر کنید تا کارشناسان ما برای هماهنگی جلسه بازدید رایگان سایت شما تماس بگیرند؛ یا مستقیم با ما در ارتباط باشید."
>
<template #title>
بیایید ایمنی را
<br />
با هم جدی بگیریم
</template>
</SectionHead>
<div class="mt-10 space-y-1">
<Reveal v-for="(item, i) in INFO" :key="item.label" :delay="0.15 + i * 0.06">
<div class="group flex items-center gap-4 border-b border-line/70 py-4">
<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" />
</span>
<div class="min-w-0">
<div class="text-xs text-muted">{{ item.label }}</div>
<div
:class="`mt-1 text-sm font-medium ${item.ltr ? 'font-mono tracking-wider' : ''}`"
:dir="item.ltr ? 'ltr' : 'rtl'"
>
{{ item.value }}
</div>
</div>
</div>
</Reveal>
</div>
<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" />
<div>
<div class="font-bold text-hazard">پاسخ‌گویی اضطراری ۲۴/۷</div>
<p class="mt-1.5 text-sm leading-7 text-muted">
برای مشتریان دارای قرارداد سازمانی، خط ویژه حوادث و تأمین
فوری تجهیزات در تمام ساعات شبانه‌روز فعال است.
</p>
</div>
</div>
</Reveal>
</div>
<!-- ---- form ---- -->
<Reveal :delay="0.15">
<div class="rounded-2xl border border-line bg-panel/60 p-6 backdrop-blur md:p-9">
<div class="font-mono text-xs tracking-[0.25em] text-accent">// فرم درخواست مشاوره</div>
<h3 class="mt-3 text-2xl font-black leading-snug">
در کمتر از ۲۴ ساعت
<br />
با شما تماس می‌گیریم
</h3>
<div
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" />
<div class="mt-5 text-xl font-black">درخواست شما ثبت شد</div>
<p class="mt-2.5 max-w-sm text-sm leading-7 text-muted">
کارشناسان ما تا ۲۴ ساعت آینده برای هماهنگی جلسه بازدید با
شما تماس می‌گیرند.
</p>
<button
type="button"
class="mt-7 rounded-full border border-line px-7 py-3 text-sm transition-colors hover:border-accent hover:text-accent"
@click="contact.reset"
>
ثبت درخواست جدید
</button>
</div>
<AForm
v-else
class="contact-form"
layout="vertical"
:model="contact.form"
:rules="rules"
hide-required-mark
@finish="contact.submit"
>
<div class="mt-8 grid gap-5 sm:grid-cols-2">
<AFormItem name="name" label="نام و نام خانوادگی *" html-for="f-name">
<AInput id="f-name" v-model:value="contact.form.name" placeholder="مثلاً: علی رضایی" />
</AFormItem>
<AFormItem name="org" label="نام سازمان *" html-for="f-org">
<AInput
id="f-org"
v-model:value="contact.form.org"
placeholder="مثلاً: شرکت پتروشیمی خلیج"
/>
</AFormItem>
<AFormItem class="sm:col-span-2" name="phone" label="شماره تماس *" html-for="f-phone">
<AInput
id="f-phone"
v-model:value="contact.form.phone"
type="tel"
dir="ltr"
placeholder="0912 000 0000"
/>
</AFormItem>
<AFormItem class="sm:col-span-2" name="message" label="شرح درخواست" html-for="f-msg">
<ATextarea
id="f-msg"
v-model:value="contact.form.message"
:rows="4"
placeholder="تجهیزات موردنیاز، تعداد پرسنل یا هر نکته‌ای که لازم است بدانیم..."
/>
</AFormItem>
<div class="sm:col-span-2">
<AButton
type="primary"
html-type="submit"
class="contact-submit"
:disabled="contact.status === 'loading'"
>
<template v-if="contact.status === 'loading'">
<Loader2 class="size-5 animate-spin" />
در حال ارسال...
</template>
<template v-else>
<Send class="size-5" />
ارسال درخواست مشاوره
</template>
</AButton>
</div>
</div>
</AForm>
</div>
</Reveal>
</div>
</div>
</section>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useInView } from "motion-v";
const props = withDefaults(
defineProps<{ end: number; suffix?: string; duration?: number }>(),
{ suffix: "", duration: 1800 },
);
const el = ref<HTMLSpanElement | null>(null);
const inView = useInView(el, { once: true, margin: "-60px" });
const val = ref(0);
let raf = 0;
watch(inView, (v) => {
if (!v) return;
const t0 = performance.now();
const tick = (t: number) => {
const p = Math.min(1, (t - t0) / props.duration);
val.value = Math.round(props.end * (1 - Math.pow(1 - p, 3)));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
});
onBeforeUnmount(() => cancelAnimationFrame(raf));
</script>
<template>
<span ref="el" class="tabular-nums">{{ val.toLocaleString("fa-IR") }}<span v-if="suffix" class="text-accent">{{ suffix }}</span></span>
</template>
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
import {
ArrowLeft,
Award,
BadgeCheck,
ChevronDown,
HardHat,
ShieldCheck,
} from "lucide-vue-next";
const CHIPS = [
{ icon: ShieldCheck, label: "ISO 9001:2015" },
{ icon: BadgeCheck, label: "CE CERTIFIED" },
{ icon: Award, label: "EN STANDARDS" },
];
const MINI_STATS = [
{ value: "۱۸+", label: "سال تجربه در صنعت ایمنی" },
{ value: "۸۵۰+", label: "سازمان تحت پوشش" },
{ value: "۹۸٪", label: "رضایت مشتریان" },
];
</script>
<template>
<section id="home" class="relative flex min-h-screen flex-col overflow-hidden">
<!-- ---- background ---- -->
<div class="absolute inset-0">
<img
src="/images/hero.jpg"
alt="کارگر صنعتی با تجهیزات ایمنی کامل"
width="1792"
height="1008"
fetchpriority="high"
decoding="async"
class="absolute inset-0 size-full object-cover"
/>
<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" />
</div>
<!-- ---- side tape ---- -->
<div class="tape-v absolute bottom-0 end-0 z-10 h-44 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"
>
<div class="grid w-full items-center gap-12 lg:grid-cols-12">
<div class="lg:col-span-7">
<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"
>
<span class="size-1.5 animate-pulse rounded-full bg-accent" />
<span class="text-xs font-medium text-accent">
تأمین‌کننده رسمی تجهیزات حفاظت فردی (PPE)
</span>
</div>
<h1
class="animate-fade-up-2 mt-7 text-5xl font-black leading-[1.15] md:text-7xl md:leading-[1.1]"
>
ایمنی یعنی
<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)]"
>
بازگشت به خانه
</span>
</h1>
<p
class="animate-fade-up-3 mt-7 max-w-xl text-base leading-9 text-ink/70 md:text-lg md:leading-10"
>
ایمن پیشگامان با بیش از ۱۸ سال تجربه، تأمین‌کننده تخصصی تجهیزات ایمنی و
حفاظت فردی برای سازمان‌هایی است که جان انسان‌ها را جدی می‌گیرند؛ از
کلاه ایمنی تا هارنس نجات، از مشاوره تا بازرسی و آموزش.
</p>
<div class="animate-fade-up-4 mt-9 flex flex-wrap items-center gap-4">
<a
href="#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)]"
>
درخواست مشاوره رایگان
<ArrowLeft
class="size-5 transition-transform duration-300 group-hover:-translate-x-1.5"
/>
</a>
<a
href="#products"
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"
>
مشاهده محصولات
</a>
</div>
<div class="animate-fade-up-5 mt-10 flex flex-wrap gap-3">
<span
v-for="c in CHIPS"
:key="c.label"
class="inline-flex items-center gap-2 rounded-full border border-ink/15 bg-bg/50 px-4 py-2 font-mono text-[11px] tracking-wider text-ink/70 backdrop-blur transition-colors hover:border-accent/50 hover:text-ink"
>
<component :is="c.icon" class="size-4 text-accent" />
{{ c.label }}
</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 class="mt-3.5 flex items-center justify-between font-mono text-[11px]">
<span class="text-muted">آخرین بازرسی</span>
<span>۱۴۰۴/۰۸/۱۲</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="-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" />
</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
v-for="s in MINI_STATS"
:key="s.label"
class="flex items-baseline gap-4 bg-bg/60 px-6 py-5 backdrop-blur-md 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>
</div>
</div>
</div>
</section>
</template>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { AnimatePresence, motion, useMotionValue, useSpring } from "motion-v";
import { ArrowLeft } from "lucide-vue-next";
import SectionHead from "~/components/ui/SectionHead.vue";
const INDUSTRIES = [
{ name: "نفت و گاز", en: "OIL & GAS", count: "۱۴۰+", img: "/images/industries/oil-gas.jpg" },
{
name: "ساختمان و عمران",
en: "CONSTRUCTION",
count: "۲۱۰+",
img: "/images/industries/construction.jpg",
},
{
name: "پتروشیمی",
en: "PETROCHEMICAL",
count: "۹۰+",
img: "/images/industries/petrochemical.jpg",
},
{ name: "صنایع معدنی", en: "MINING", count: "۷۵+", img: "/images/industries/mining.jpg" },
{
name: "برق و انرژی",
en: "POWER & ENERGY",
count: "۶۰+",
img: "/images/industries/power-energy.jpg",
},
{
name: "حمل‌ونقل و لجستیک",
en: "TRANSPORT",
count: "۴۵+",
img: "/images/industries/transport.jpg",
},
];
const hovered = ref<number | null>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, { stiffness: 220, damping: 26, mass: 0.6 });
const sy = useSpring(y, { stiffness: 220, damping: 26, mass: 0.6 });
const onMove = (e: MouseEvent) => {
x.set(e.clientX);
y.set(e.clientY);
};
</script>
<template>
<section
id="industries"
aria-labelledby="industries-title"
class="relative border-t border-line py-24 md:py-32"
@mousemove="onMove"
>
<div class="mx-auto max-w-[1480px] px-5 md:px-8">
<SectionHead
heading-id="industries-title"
tag="۰۵ — صنایع"
desc="جایی که دمای محیط از ۵۰ درجه گذر می‌کند یا ارتفاع کار به ده‌ها متر می‌رسد، تجهیزات ما باید بی‌نقص عمل کنند — و عمل می‌کنند."
>
<template #title>
حضور در سخت‌ترین
<br />
میدان‌های کاری
</template>
</SectionHead>
<div class="mt-16" @mouseleave="hovered = null">
<motion.div
v-for="(ind, i) in INDUSTRIES"
:key="ind.name"
v-bind="fade(i * 0.05)"
data-hover
:class="`group relative flex cursor-pointer items-center justify-between gap-4 border-t border-line px-2 py-7 transition-colors duration-300 hover:bg-panel/40 md:px-4 md:py-9 ${
i === INDUSTRIES.length - 1 ? 'border-b' : ''
}`"
@mouseenter="hovered = i"
>
<div class="flex min-w-0 items-baseline gap-5 md:gap-8">
<span class="shrink-0 font-mono text-xs text-muted">
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
<h3
class="truncate text-2xl font-black transition-all duration-300 group-hover:-translate-x-2 group-hover:text-accent md:text-5xl"
>
{{ ind.name }}
</h3>
<span
class="hidden shrink-0 font-mono text-[10px] tracking-[0.3em] text-muted/60 md:inline"
>
{{ ind.en }}
</span>
</div>
<div class="flex shrink-0 items-center gap-4 md:gap-8">
<span class="text-xs text-muted md:text-sm">{{ ind.count }} پروژه</span>
<span
class="flex size-10 items-center justify-center rounded-full border border-line text-muted transition-all duration-300 group-hover:-translate-x-1.5 group-hover:border-accent group-hover:text-accent md:size-12"
>
<ArrowLeft class="size-4 md:size-5" />
</span>
</div>
</motion.div>
</div>
</div>
<!-- ---- floating preview (desktop) ---- -->
<motion.div
:style="{ x: sx, y: sy }"
class="pointer-events-none fixed left-0 top-0 z-40 hidden lg:block"
>
<AnimatePresence>
<motion.div
v-if="hovered !== null"
:key="hovered"
:initial="{ opacity: 0, scale: 0.82, rotate: -3 }"
:animate="{ opacity: 1, scale: 1, rotate: 0 }"
:exit="{ opacity: 0, scale: 0.9, rotate: 2 }"
:transition="{ duration: 0.35, ease: EASE }"
class="relative -ml-40 -mt-28 h-56 w-80 overflow-hidden rounded-xl border border-accent/40 shadow-[0_25px_80px_rgba(0,0,0,0.6)]"
>
<img
:src="INDUSTRIES[hovered]!.img"
:alt="INDUSTRIES[hovered]!.name"
decoding="async"
class="absolute inset-0 size-full object-cover grayscale-[35%] transition-all duration-500"
/>
<div class="absolute inset-0 bg-accent/15 mix-blend-multiply" />
<div class="absolute inset-0 bg-gradient-to-t from-bg/70 to-transparent" />
<div
class="absolute bottom-3 start-3 rounded-md border border-line bg-bg/80 px-3 py-1.5 font-mono text-[10px] tracking-widest text-ink backdrop-blur"
>
{{ INDUSTRIES[hovered]!.en }}
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</section>
</template>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ArrowLeft, BadgeCheck, PackageCheck, ShieldCheck } from "lucide-vue-next";
import { CATEGORIES } from "#shared/data/categories";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const CATEGORY_IMAGES: Record<string, string> = {
workwear: "/images/products/vest.jpg",
"safety-footwear": "/images/products/shoes.jpg",
"protective-gloves": "/images/products/gloves.jpg",
"safety-helmets": "/images/products/helmet.jpg",
"eye-protection": "/images/products/goggles.jpg",
"hearing-protection": "/images/products/hearing.jpg",
"respiratory-protection": "/images/products/respiratory.jpg",
"traffic-equipment": "/images/products/traffic.jpg",
};
const TRUST = [
{
icon: BadgeCheck,
title: "گارانتی اصالت کالا",
desc: "تمام محصولات با فاکتور رسمی و کد رهگیری اصالت",
},
{
icon: PackageCheck,
title: "تست نمونه قبل از خرید",
desc: "نمونه‌های نمایشی برای ارزیابی تیم HSE شما",
},
{
icon: ShieldCheck,
title: "امکان قرارداد سالانه",
desc: "قیمت ثابت و تأمین مستمر برای سازمان‌های بزرگ",
},
];
const active = ref(0);
</script>
<template>
<section id="products" aria-labelledby="products-title" class="relative 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"
tag="۰۱ — محصولات"
desc="هر محصول پیش از عرضه، زیر ذره‌بین کنترل کیفیت و تطبیق با استانداردهای جهانی قرار می‌گیرد."
>
<template #title>
خط اول دفاع،
<br />
تجهیزات ماست
</template>
</SectionHead>
<Reveal :delay="0.2" class="hidden md:block">
<a
href="/#contact"
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" />
</a>
</Reveal>
</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"
>
<img
v-if="CATEGORY_IMAGES[cat.slug]"
:src="CATEGORY_IMAGES[cat.slug]"
:alt="cat.name"
loading="lazy"
decoding="async"
class="absolute inset-0 size-full object-cover transition-transform duration-500 group-active:scale-105"
/>
<div v-else class="absolute inset-0 bg-panel" />
<div class="absolute inset-0 bg-gradient-to-t from-bg via-bg/50 to-transparent" />
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-3">
<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>
<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.en }}</p>
</div>
</NuxtLink>
</Reveal>
</div>
</div>
<!-- ---- desktop: accordion panels ---- -->
<div class="mx-auto mt-14 hidden max-w-[1480px] px-8 md:block">
<div class="flex h-[74vh] min-h-[580px] flex-row gap-2">
<NuxtLink
v-for="(cat, i) in CATEGORIES"
:key="cat.code"
:to="`/categories/${cat.slug}`"
data-hover
:class="`group relative cursor-pointer overflow-hidden rounded-xl border transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
active === i
? 'flex-[3.4] border-accent/60'
: 'flex-[1] border-line hover:border-ink/30'
}`"
@mouseenter="active = i"
>
<img
v-if="CATEGORY_IMAGES[cat.slug]"
:src="CATEGORY_IMAGES[cat.slug]"
:alt="cat.name"
loading="lazy"
decoding="async"
:class="`absolute inset-0 size-full object-cover transition-all duration-700 ${
active === i ? 'scale-100 grayscale-0' : 'scale-110 grayscale group-hover:grayscale-0'
}`"
/>
<div
v-else
class="absolute inset-0 bg-panel"
/>
<div
: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'
}`"
/>
<div class="absolute inset-x-0 top-0 flex items-center justify-between p-5">
<span class="font-mono text-[10px] tracking-widest text-ink/60">{{ cat.code }}</span>
<span
:class="`font-mono text-xs transition-colors duration-500 ${
active === i ? 'text-accent' : 'text-ink/40'
}`"
>
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
</div>
<div class="absolute inset-x-0 bottom-0 p-6">
<h3
:class="`font-black transition-all duration-500 ${
active === i ? 'text-2xl' : 'text-sm'
}`"
>
{{ cat.name }}
</h3>
<p class="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted">{{ cat.en }}</p>
</div>
</NuxtLink>
</div>
</div>
<div
class="mx-auto mt-10 grid max-w-[1480px] gap-px overflow-hidden rounded-xl border border-line bg-line md:grid-cols-3"
>
<Reveal v-for="(t, i) in TRUST" :key="t.title" :delay="i * 0.08">
<div class="flex h-full items-center gap-4 bg-panel/60 p-6">
<span
class="flex size-12 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent"
>
<component :is="t.icon" class="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>
</div>
</Reveal>
</div>
</section>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { ClipboardCheck, Gauge, GraduationCap, Plus, Truck } from "lucide-vue-next";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
import { serviceSchema } from "#shared/seo/schema";
const SERVICES = [
{
icon: ClipboardCheck,
title: "بازرسی و ارزیابی ریسک",
desc: "تیم مهندسی ما با بازدید از سایت شما، خطرات را شناسایی، اولویت‌بندی و برای هر کدام راهکار اجرایی مشخص با برآورد هزینه ارائه می‌دهد.",
items: [
"ارزیابی HAZOP و HAZID",
"شناسایی و اولویت‌بندی خطرات",
"گزارش مدیریتی با راهکار اجرایی",
],
},
{
icon: GraduationCap,
title: "آموزش تخصصی ایمنی",
desc: "از دوره‌های عمومی تا تربیت مربی؛ آموزش‌های عملی و میدانی که ایمنی را از دفتر کلان‌ها به رفتار روزمره پرسنل تبدیل می‌کند.",
items: [
"دوره‌های عمومی و تخصصی HSE",
"تربیت مربی ایمنی سایت",
"مانور و شبیه‌سازی شرایط اضطراری",
],
},
{
icon: Gauge,
title: "تست و کالیبراسیون تجهیزات",
desc: "تجهیزات ایمنی فقط وقتی قابل اعتمادند که تست شده باشند. تست دوره‌ای، سرویس و صدور گواهی معتبر را به تیم تخصصی ما بسپارید.",
items: [
"تست هارنس و لاین نجات",
"کالیبراسیون آشکارسازهای گاز",
"سرویس دوره‌ای و صدور گواهی",
],
},
{
icon: Truck,
title: "تأمین و لجستیک سازمانی",
desc: "با قرارداد سالانه، قیمت‌گذاری شفاف و انبار محلی در سایت شما، نگرانی کمبود تجهیزات را از فهرست دغدغه‌هایتان حذف کنید.",
items: [
"قرارداد سالانه با قیمت ثابت",
"انبار محلی در سایت شما",
"تحویل سراسری در ۴۸ ساعت",
],
},
];
useJsonLd(
SERVICES.map((s) => serviceSchema({ name: s.title, description: s.desc })),
"services",
);
const open = ref(0);
</script>
<template>
<section id="services" aria-labelledby="services-title" class="border-t border-line py-24 md:py-32">
<div class="mx-auto max-w-[1480px] px-5 md:px-8">
<SectionHead
heading-id="services-title"
tag="۰۳ — خدمات"
desc="ما در ایمن پیشگامان، تجهیزات را تحویل می‌دهیم و کنار شما می‌مانیم؛ از نخستین ارزیابی ریسک تا آخرین تست دوره‌ای."
>
<template #title>
فراتر از تأمین؛
<br />
همراهی کامل ایمنی
</template>
</SectionHead>
<div class="mt-16">
<Reveal v-for="(s, i) in SERVICES" :key="s.title" :delay="i * 0.05">
<div :class="`border-t border-line ${i === SERVICES.length - 1 ? 'border-b' : ''}`">
<button
class="group flex w-full items-center gap-5 py-7 text-start md:gap-10 md:py-9"
@click="open = open === i ? -1 : i"
>
<span
:class="`font-mono text-sm transition-colors duration-300 md:text-base ${
open === i ? 'text-accent' : 'text-muted'
}`"
>
{{ faNum(String(i + 1).padStart(2, "0")) }}
</span>
<span
:class="`flex-1 text-2xl font-black transition-colors duration-300 md:text-4xl ${
open === i ? 'text-accent' : 'text-ink group-hover:text-accent'
}`"
>
{{ s.title }}
</span>
<span
:class="`hidden size-12 shrink-0 items-center justify-center rounded-full border transition-all duration-500 md:flex ${
open === i
? 'rotate-45 border-accent bg-accent text-bg'
: 'border-line text-muted group-hover:border-accent/50 group-hover:text-accent'
}`"
>
<Plus class="size-5" />
</span>
</button>
<AnimatePresence :initial="false">
<motion.div
v-if="open === i"
:initial="{ height: 0, opacity: 0 }"
:animate="{ height: 'auto', opacity: 1 }"
:exit="{ height: 0, opacity: 0 }"
:transition="{ duration: 0.55, ease: EASE }"
class="overflow-hidden"
>
<div class="grid gap-8 pb-10 md:grid-cols-2 md:ps-16">
<div class="flex items-start gap-4">
<span
class="flex size-12 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent"
>
<component :is="s.icon" class="size-6" />
</span>
<p class="text-sm leading-8 text-muted">{{ s.desc }}</p>
</div>
<ul class="space-y-3.5">
<li
v-for="it in s.items"
:key="it"
class="flex items-center gap-3 text-sm text-ink/80"
>
<span class="size-1.5 shrink-0 rounded-full bg-accent" />
{{ it }}
</li>
</ul>
</div>
</motion.div>
</AnimatePresence>
</div>
</Reveal>
</div>
</div>
</section>
</template>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { FileSearch, ShieldCheck } from "lucide-vue-next";
import Reveal from "~/components/ui/Reveal.vue";
import SectionHead from "~/components/ui/SectionHead.vue";
const STANDARDS = [
{ code: "EN 397", label: "کلاه ایمنی صنعتی" },
{ code: "EN 388", label: "دستکش‌های مکانیکی" },
{ code: "EN 166", label: "محافظ چشم و صورت" },
{ code: "EN 361", label: "هارنس ضدسقوط" },
{ code: "ISO 20345", label: "کفش ایمنی" },
{ code: "ISO 9001", label: "سیستم مدیریت کیفیت" },
];
</script>
<template>
<section
id="standards"
aria-labelledby="standards-title"
class="relative overflow-hidden border-t border-line py-24 md:py-32"
>
<!-- watermark -->
<div
aria-hidden="true"
class="text-outline pointer-events-none absolute -bottom-14 -start-8 select-none font-mono text-[24vw] font-bold leading-none opacity-60"
>
CE
</div>
<div class="relative mx-auto max-w-[1480px] px-5 md:px-8">
<div class="grid items-center gap-14 lg:grid-cols-12">
<div class="lg:col-span-5">
<SectionHead
heading-id="standards-title"
tag="۰۴ — استانداردها"
desc="ما فقط تجهیزاتی را عرضه می‌کنیم که تأییدیه معتبر بین‌المللی و تست آزمایشگاهی داشته باشند؛ بدون استثنا."
>
<template #title>
مهرتأیید جهانی
<br />
روی هر محصول
</template>
</SectionHead>
<Reveal :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" />
<p class="text-sm leading-8 text-ink/80">
شماره سریال تمام محصولات در پایگاه داده استانداردهای اروپا
قابل استعلام است و گواهی تست هر بچ تحویل مشتری می‌شود.
</p>
</div>
</Reveal>
<Reveal :delay="0.35">
<a
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" />
درخواست گواهی تست محصولات
</a>
</Reveal>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:col-span-7 xl:grid-cols-3">
<Reveal v-for="(s, i) in STANDARDS" :key="s.code" :delay="i * 0.06">
<div
data-hover
class="group relative h-full border border-line bg-panel/50 p-6 text-center transition-all duration-500 hover:-translate-y-1.5 hover:border-accent/50 hover:bg-panel"
>
<span class="absolute end-3 top-3 font-mono text-[9px] tracking-[0.2em] text-muted/50">
CERT
</span>
<div
class="mx-auto flex size-24 items-center justify-center rounded-full border border-dashed border-ink/20 transition-all duration-500 group-hover:rotate-12 group-hover:border-accent/60"
>
<div>
<div
class="font-mono text-base font-bold text-ink transition-colors duration-300 group-hover:text-accent"
>
{{ s.code }}
</div>
<div
class="mx-auto mt-1.5 h-px w-8 bg-line transition-colors duration-300 group-hover:bg-accent/50"
/>
<div class="mt-1.5 font-mono text-[8px] tracking-[0.25em] text-muted">
EURO NORM
</div>
</div>
</div>
<div class="mt-4 text-sm font-medium text-ink/85">{{ s.label }}</div>
</div>
</Reveal>
</div>
</div>
</div>
</section>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import Reveal from "~/components/ui/Reveal.vue";
import CountUp from "./CountUp.vue";
const STATS = [
{ end: 18, suffix: "+", label: "سال تجربه در صنعت ایمنی", note: "SINCE 2007" },
{ end: 2400, suffix: "+", label: "کد کالای ایمنی", note: "PPE CATALOG" },
{ end: 850, suffix: "+", label: "مشتری سازمانی فعال", note: "B2B CLIENTS" },
{ end: 98, suffix: "٪", label: "رضایت مشتریان", note: "SATISFACTION" },
];
</script>
<template>
<section class="relative" aria-label="آمار و دستاوردهای ایمن پیشگامان">
<div class="grid grid-cols-2 gap-px border-y border-line bg-line lg:grid-cols-4">
<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"
>
<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"
>
{{ s.note }}
</span>
<Reveal :delay="i * 0.08">
<div class="text-4xl font-black md:text-6xl">
<CountUp :end="s.end" :suffix="s.suffix" />
</div>
<div class="mt-3 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"
/>
</div>
</div>
</section>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { ShieldCheck } from "lucide-vue-next";
const ITEMS = [
"ISO 9001:2015",
"CE CERTIFIED",
"گارانتی اصالت کالا",
"تحویل سراسری کشوری",
"مشاوره و بازرسی تخصصی رایگان",
"EN STANDARDS",
"پشتیبانی ۲۴/۷ قراردادهای سازمانی",
"تست نمونه قبل از خرید",
];
</script>
<template>
<section aria-hidden="true" class="pointer-events-none relative z-20 overflow-hidden 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="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"
>
{{ item }}
<ShieldCheck class="size-4 shrink-0" :stroke-width="2.5" />
</span>
</div>
</div>
<div class="hazard h-2.5" />
</div>
</section>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { AnimatePresence, motion } from "motion-v";
import { Quote } from "lucide-vue-next";
import SectionHead from "~/components/ui/SectionHead.vue";
const QUOTES = [
{
text: "ایمن پیشگامان فقط تأمین‌کننده نیست؛ شریک ایمنی ماست. بازرسی‌های دوره‌ای و آموزش‌های میدانی‌شان نرخ حوادث سایت را به کمترین رقم تاریخ مجموعه رساند.",
name: "مهندس رضا کاظمی",
role: "مدیر HSE — مجتمع پتروشیمی خلیج فارس",
},
{
text: "کیفیت تجهیزات و سرعت پاسخ‌گویی در سطحی بود که قرارداد سالانه را بدون هیچ تردیدی تمدید کردیم؛ از خط تولید تا دفتر مرکزی.",
name: "سارا محمدی",
role: "سرپرست تدارکات — هلدینگ ساختمانی آرکان",
},
{
text: "۱۴۰ کیلومتر دورتر از سایت، در عرض ۲۴ ساعت تجهیزات حیاتی را تحویل گرفتیم. به این می‌گویند شریک واقعی کاری.",
name: "امیرحسین راد",
role: "مدیر پروژه — صنایع معدنی کاوه",
},
];
const idx = ref(0);
const q = computed(() => QUOTES[idx.value]!);
let t: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
t = setInterval(() => (idx.value = (idx.value + 1) % QUOTES.length), 7000);
});
onBeforeUnmount(() => clearInterval(t));
</script>
<template>
<section
aria-labelledby="testimonials-title"
class="relative overflow-hidden border-t border-line py-24 md:py-32"
>
<div class="absolute inset-0 bg-grid opacity-40" />
<div
class="pointer-events-none absolute -top-10 -end-10 select-none font-mono text-[18vw] font-bold leading-none text-outline opacity-50"
>
"
</div>
<div class="relative mx-auto max-w-4xl px-5 text-center md:px-8">
<SectionHead
heading-id="testimonials-title"
center
tag="۰۶ — اعتماد"
title="آنچه مشتریان ما می‌گویند"
/>
<div class="mt-14 flex min-h-[320px] flex-col items-center md:min-h-[280px]">
<span
class="flex size-16 items-center justify-center rounded-full border border-accent/30 bg-accent/10"
>
<Quote class="size-7 text-accent" />
</span>
<AnimatePresence mode="wait">
<motion.blockquote
:key="idx"
:initial="{ opacity: 0, y: 26 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -26 }"
:transition="{ duration: 0.55, ease: EASE }"
class="mt-8"
>
<p class="text-xl font-bold leading-[2.1] text-ink/90 md:text-2xl md:leading-[2]">
«{{ q.text }}»
</p>
<footer class="mt-8">
<div class="text-lg font-black">{{ q.name }}</div>
<div class="mt-2 font-mono text-xs text-muted">{{ q.role }}</div>
</footer>
</motion.blockquote>
</AnimatePresence>
</div>
<div class="mt-12 flex items-center justify-center gap-3">
<button
v-for="(_, i) in QUOTES"
:key="i"
:aria-label="`نظر شماره ${faNum(i + 1)}`"
:class="`h-1.5 rounded-full transition-all duration-500 ${
i === idx ? 'w-12 bg-accent' : 'w-4 bg-line hover:bg-muted'
}`"
@click="idx = i"
/>
</div>
</div>
</section>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { ImageOff } from "lucide-vue-next";
withDefaults(
defineProps<{
label?: string;
aspectClass?: string;
}>(),
{
label: "تصویر محصول",
aspectClass: "aspect-[4/3]",
},
);
</script>
<template>
<div
:class="`flex flex-col items-center justify-center border border-dashed border-line bg-panel ${aspectClass}`"
>
<ImageOff class="size-10 text-muted/30" />
<span class="mt-2 font-mono text-[10px] tracking-wider text-muted/40">{{ label }}</span>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
</script>
<template>
<NuxtLink to="/" class="flex items-center gap-3" aria-label="ایمن پیشگامان">
<span
class="flex size-10 items-center justify-center rounded-xl bg-white shadow-[0_0_28px_rgba(10,30,92,0.45)]"
>
<img
src="/images/logo.png"
alt="ایمن پیشگامان"
width="28"
height="28"
class="size-7 object-contain"
/>
</span>
<span class="leading-tight">
<span class="block text-lg font-black">ایمن پیشگامان</span>
<span class="block font-mono text-[9px] tracking-[0.32em] text-muted">
IMEN PISHGAMAN CO.
</span>
</span>
</NuxtLink>
</template>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { motion } from "motion-v";
const props = withDefaults(defineProps<{ delay?: number }>(), { delay: 0 });
const motionProps = computed(() => fade(props.delay, 34));
</script>
<template>
<motion.div v-bind="motionProps">
<slot />
</motion.div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import Reveal from "./Reveal.vue";
withDefaults(
defineProps<{
tag: string;
/** plain-text title; use the #title slot for rich markup (line breaks, spans) */
title?: string;
desc?: string;
center?: boolean;
headingId?: string;
}>(),
{ center: false },
);
</script>
<template>
<div :class="center ? 'text-center' : ''">
<Reveal>
<div :class="`flex items-center gap-3 ${center ? 'justify-center' : ''}`">
<span class="font-mono text-sm text-accent">//</span>
<span class="font-mono text-xs tracking-[0.25em] text-muted">
{{ tag }}
</span>
<span class="h-px w-14 bg-line" />
</div>
</Reveal>
<Reveal :delay="0.08">
<h2
:id="headingId"
class="mt-5 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]"
>
<slot name="title">{{ title }}</slot>
</h2>
</Reveal>
<Reveal v-if="desc" :delay="0.16">
<p :class="`mt-5 max-w-xl leading-8 text-muted ${center ? 'mx-auto' : ''}`">
{{ desc }}
</p>
</Reveal>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
import { toJsonLd } from "#shared/seo/schema";
type Ld = Record<string, unknown>;
/** Renders JSON-LD <script> tags at the start of <body> (replaces the React <JsonLd> component). */
export function useJsonLd(data: Ld | Ld[], id = "site") {
const items = Array.isArray(data) ? data : [data];
useHead({
script: items.map((item, i) => ({
key: `ld-${id}-${i}`,
type: "application/ld+json",
innerHTML: toJsonLd(item),
tagPosition: "bodyOpen" as const,
})),
});
}
+13
View File
@@ -0,0 +1,13 @@
/** Reactive `window.scrollY > threshold`, evaluated after mount (passive listener). */
export function useScrolledPast(threshold: number) {
const past = ref(false);
const on = () => (past.value = window.scrollY > threshold);
onMounted(() => {
on();
window.addEventListener("scroll", on, { passive: true });
});
onBeforeUnmount(() => window.removeEventListener("scroll", on));
return past;
}
+28
View File
@@ -0,0 +1,28 @@
import { absUrl, siteConfig as s } from "#shared/config/site";
/** Site-wide <head> defaults shared by the app shell and the error page. */
export function useSiteSeo() {
useHead({
titleTemplate: (title) => (title ? `${title} | ${s.name}` : s.title),
});
useSeoMeta({
description: s.description,
themeColor: s.themeColor,
formatDetection: "telephone=no",
ogType: "website",
ogSiteName: s.name,
ogLocale: s.locale,
ogTitle: s.title,
ogDescription: s.description,
ogImage: absUrl(s.ogImage.src),
ogImageWidth: s.ogImage.width,
ogImageHeight: s.ogImage.height,
ogImageAlt: s.ogImage.alt,
twitterCard: "summary_large_image",
twitterTitle: s.title,
twitterDescription: s.description,
twitterImage: absUrl(s.ogImage.src),
twitterImageAlt: s.ogImage.alt,
});
}
+36
View File
@@ -0,0 +1,36 @@
/* Starts with "--:--" on the server so the first client render matches,
then sets the real Tehran time only after mount (avoids a hydration mismatch on the clock text) */
export function useTehranClock() {
const now = ref<Date | null>(null);
let t: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
now.value = new Date();
t = setInterval(() => (now.value = new Date()), 15000);
});
onBeforeUnmount(() => clearInterval(t));
const time = computed(() =>
now.value
? new Intl.DateTimeFormat("fa-IR", {
hour: "2-digit",
minute: "2-digit",
timeZone: "Asia/Tehran",
}).format(now.value)
: "--:--",
);
const shift = computed(() => {
if (!now.value) return "";
const hour = Number(
new Intl.DateTimeFormat("en-US", {
hour: "numeric",
hour12: false,
timeZone: "Asia/Tehran",
}).format(now.value),
);
return hour >= 6 && hour < 14 ? "صبح" : hour >= 14 && hour < 22 ? "عصر" : "شب";
});
return { time, shift };
}
+44
View File
@@ -0,0 +1,44 @@
import { theme } from "ant-design-vue";
import type { ThemeConfig } from "ant-design-vue/es/config-provider/context";
/**
* Design-system palette — mirrors the Tailwind `@theme` tokens in assets/css/main.css.
* Keep both in sync: Tailwind drives layout/sections, Ant Design drives form controls.
*/
export const palette = {
bg: "#0a0a0c",
panel: "#121216",
panel2: "#17171c",
ink: "#edede6",
muted: "#8e8e96",
accent: "#ff5a00",
accentSoft: "#ff7a2a",
hazard: "#ffb800",
line: "#232329",
success: "#34d399", // tailwind emerald-400
} as const;
export const antdTheme: ThemeConfig = {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: palette.accent,
colorInfo: palette.accent,
colorWarning: palette.hazard,
colorSuccess: palette.success,
colorBgBase: palette.bg,
colorBgContainer: palette.bg,
colorBgElevated: palette.panel,
colorBorder: palette.line,
colorBorderSecondary: palette.line,
colorText: palette.ink,
colorTextDescription: palette.muted,
colorTextPlaceholder: "rgba(142, 142, 150, 0.5)",
colorTextLightSolid: palette.bg,
fontFamily: "var(--font-sans)",
fontSize: 14,
borderRadius: 8,
controlOutlineWidth: 0,
motionDurationMid: "0.15s",
wireframe: false,
},
};
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { NuxtError } from "#app";
import { ShieldAlert } from "lucide-vue-next";
const props = defineProps<{ error: NuxtError }>();
const notFound = computed(() => props.error.statusCode === 404);
useSiteSeo();
useHead({ title: () => (notFound.value ? "صفحه پیدا نشد" : "خطا") });
useSeoMeta({ robots: "noindex, follow" });
const goHome = () => clearError({ redirect: "/" });
</script>
<template>
<main class="flex min-h-screen flex-col items-center justify-center px-5 text-center">
<div class="hazard absolute inset-x-0 top-0 h-2.5" />
<ShieldAlert class="size-16 text-accent" />
<h1 class="mt-8 text-4xl font-black md:text-6xl">
{{ notFound ? "صفحه مورد نظر پیدا نشد" : "خطایی رخ داد" }}
</h1>
<p class="mt-5 max-w-md leading-8 text-muted">
{{
notFound
? "ممکن است آدرس اشتباه وارد شده باشد یا این صفحه جابه‌جا شده باشد."
: "لطفاً چند لحظه بعد دوباره تلاش کنید."
}}
</p>
<a
href="/"
class="mt-9 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"
@click.prevent="goHome"
>
بازگشت به صفحه اصلی
</a>
</main>
</template>
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { ArrowRight, Phone } from "lucide-vue-next";
import { absUrl } from "#shared/config/site";
import { CATEGORIES, findCategory } from "#shared/data/categories";
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";
const CATEGORY_IMAGES: Record<string, string> = {
workwear: "/images/products/vest.jpg",
"safety-footwear": "/images/products/shoes.jpg",
"protective-gloves": "/images/products/gloves.jpg",
"safety-helmets": "/images/products/helmet.jpg",
"eye-protection": "/images/products/goggles.jpg",
"hearing-protection": "/images/products/hearing.jpg",
"respiratory-protection": "/images/products/respiratory.jpg",
"traffic-equipment": "/images/products/traffic.jpg",
};
const route = useRoute();
const slug = route.params.slug as string;
const category = findCategory(slug);
if (!category) {
throw createError({ statusCode: 404, statusMessage: "دسته‌بندی یافت نشد" });
}
const categoryIndex = CATEGORIES.findIndex((c) => c.slug === slug);
const canonical = absUrl(`/categories/${slug}`);
useHead({
title: `${category.name} | ایمن پیشگامان`,
link: [{ rel: "canonical", href: canonical }],
});
useSeoMeta({
description: category.fullDesc,
robots: "index, follow",
ogUrl: canonical,
ogTitle: `${category.name} | ایمن پیشگامان`,
ogDescription: category.fullDesc,
});
useJsonLd(categoryPageSchema(category), `category-${slug}`);
const activeGroup = ref<string | null>(null);
const visibleProducts = computed(() =>
activeGroup.value
? category.products.filter((p) => p.group === activeGroup.value)
: category.products,
);
const activeGroupDesc = computed(
() => category.groups?.find((g) => g.slug === activeGroup.value)?.desc,
);
const groupCount = (g: string) => category.products.filter((p) => p.group === g).length;
</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_IMAGES[slug]"
:src="CATEGORY_IMAGES[slug]"
: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"
>
<template #title>{{ category.name }}</template>
</SectionHead>
</div>
<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.en }}
</span>
</div>
</Reveal>
</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="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)]"
>
<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_IMAGES[slug]"
: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.en }}
</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>
</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>
</section>
</main>
<Footer />
</AppShell>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { absUrl } from "#shared/config/site";
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";
import Products from "~/components/sections/Products.vue";
import About from "~/components/sections/About.vue";
import Services from "~/components/sections/Services.vue";
import Standards from "~/components/sections/Standards.vue";
import Industries from "~/components/sections/Industries.vue";
import Testimonials from "~/components/sections/Testimonials.vue";
import Contact from "~/components/sections/Contact.vue";
const canonical = absUrl("/");
useHead({
link: [
{ rel: "canonical", href: canonical },
{ rel: "preload", as: "image", href: "/images/hero.jpg", fetchpriority: "high" },
],
});
useSeoMeta({
robots: "index, follow",
ogUrl: canonical,
});
</script>
<template>
<AppShell>
<main>
<Hero />
<TapeMarquee />
<Stats />
<Products />
<About />
<Services />
<Standards />
<Industries />
<Testimonials />
<Contact />
</main>
<Footer />
</AppShell>
</template>
+22
View File
@@ -0,0 +1,22 @@
import { createCache, extractStyle } from "ant-design-vue";
/**
* Ant Design Vue uses CSS-in-JS. During SSR/SSG we collect the generated styles into a cache
* and inline them into <head>, so prerendered HTML is fully styled before hydration (no FOUC).
*/
export default defineNuxtPlugin((nuxtApp) => {
const cache = createCache();
if (import.meta.server) {
nuxtApp.hook("app:rendered", ({ ssrContext }) => {
const css = extractStyle(cache, true);
if (css && ssrContext) {
ssrContext.head.push({
style: [{ key: "antd-cssinjs", innerHTML: css }],
});
}
});
}
return { provide: { antdCache: cache } };
});
+24
View File
@@ -0,0 +1,24 @@
import { defineStore } from "pinia";
export type ContactStatus = "idle" | "loading" | "success";
const emptyForm = () => ({ name: "", org: "", phone: "", message: "" });
export const useContactStore = defineStore("contact", {
state: () => ({
form: emptyForm(),
status: "idle" as ContactStatus,
}),
actions: {
// TODO: connect to the real API endpoint; currently simulates the request like the original
async submit() {
this.status = "loading";
await new Promise((r) => setTimeout(r, 1100));
this.status = "success";
},
reset() {
this.form = emptyForm();
this.status = "idle";
},
},
});
+21
View File
@@ -0,0 +1,21 @@
import { defineStore } from "pinia";
export const useUiStore = defineStore("ui", {
state: () => ({
/** true once the preloader has finished */
ready: false,
/** mobile navigation overlay */
menuOpen: false,
}),
actions: {
setReady() {
this.ready = true;
},
openMenu() {
this.menuOpen = true;
},
closeMenu() {
this.menuOpen = false;
},
},
});
+13
View File
@@ -0,0 +1,13 @@
export const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Convert latin digits to Persian digits */
export const faNum = (v: number | string): string =>
String(v).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]!);
/** While-in-view fade-up props for motion elements (spread with v-bind) */
export const fade = (delay = 0, y = 36) => ({
initial: { opacity: 0, y },
whileInView: { opacity: 1, y: 0 },
inViewOptions: { once: true, margin: "-60px" as const },
transition: { duration: 0.8, delay, ease: EASE },
});
-12
View File
@@ -1,12 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
trailingSlash: false,
images: {
unoptimized: true,
},
poweredByHeader: false,
};
export default nextConfig;
+6 -1
View File
@@ -4,11 +4,16 @@ server {
root /usr/share/nginx/html;
index index.html;
location /_next/static/ {
location /_nuxt/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# mime.types پیش‌فرض nginx نوع webmanifest را ندارد
location = /manifest.webmanifest {
default_type application/manifest+json;
}
# $uri/index.html به‌جای $uri/ تا دایرکتوری / ریدایرکت ۳۰۱ نسازد
location / {
try_files $uri $uri.html $uri/index.html =404;
+78
View File
@@ -0,0 +1,78 @@
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import { siteConfig } from "./shared/config/site";
import { categorySlugs } from "./shared/data/categories";
/** Unmatched path rendered through error.vue at build time and saved as out/404.html */
const NOT_FOUND_ROUTE = "/__not-found";
export default defineNuxtConfig({
compatibilityDate: "2026-10-01",
ssr: true,
devtools: { enabled: false },
modules: ["@pinia/nuxt"],
css: ["~/assets/css/main.css", "~/assets/css/antd-overrides.css"],
app: {
head: {
htmlAttrs: { lang: siteConfig.lang, dir: siteConfig.dir },
bodyAttrs: {
class: "relative min-h-screen bg-bg font-sans text-ink antialiased",
},
charset: "utf-8",
viewport: "width=device-width, initial-scale=1",
link: [
{
rel: "preload",
href: "/fonts/IRANSansWebFaNum.woff2",
as: "font",
type: "font/woff2",
crossorigin: "",
},
{
rel: "preload",
href: "/fonts/IRANSansWebFaNum_Bold.woff2",
as: "font",
type: "font/woff2",
crossorigin: "",
},
{ rel: "icon", type: "image/svg+xml", href: siteConfig.favicon },
{ rel: "manifest", href: "/manifest.webmanifest" },
],
},
},
vite: {
plugins: [tailwindcss()],
ssr: { noExternal: ["ant-design-vue", "@ant-design/icons-vue"] },
},
// Static export (`npm run build` → ./out), served by nginx in Docker — same flow as the Next build.
nitro: {
output: { publicDir: fileURLToPath(new URL("./out", import.meta.url)) },
prerender: {
crawlLinks: false,
routes: [
"/",
"/robots.txt",
"/sitemap.xml",
"/manifest.webmanifest",
NOT_FOUND_ROUTE,
...categorySlugs.map((s) => `/categories/${s}`),
],
},
hooks: {
// Nuxt's own /404.html is an empty SPA shell. Replace it with a server-rendered error.vue
// (real content + noindex in the static HTML) produced by prerendering an unmatched route.
"prerender:generate"(route) {
if (route.route === "/404.html") route.skip = true;
if (route.route === NOT_FOUND_ROUTE && route.error?.statusCode === 404) {
route.error = undefined;
route.fileName = "/404.html";
}
},
},
},
typescript: { strict: true },
});
+1
View File
@@ -0,0 +1 @@
<!DOCTYPE html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><script type="importmap">{"imports":{"#entry":"/_nuxt/CwuOmQRC.js"}}</script><link rel="stylesheet" href="/_nuxt/entry.DXyzAtZq.css" crossorigin><link rel="preload" href="/fonts/IRANSansWebFaNum.woff2" as="font" type="font/woff2" crossorigin><link rel="preload" href="/fonts/IRANSansWebFaNum_Bold.woff2" as="font" type="font/woff2" crossorigin><link rel="modulepreload" as="script" crossorigin href="/_nuxt/CwuOmQRC.js"><link rel="modulepreload" as="script" crossorigin href="/_nuxt/BLoJslU_.js"><script type="module" src="/_nuxt/CwuOmQRC.js" crossorigin></script><link rel="icon" type="image/svg+xml" href="/favicon.svg"><link rel="manifest" href="/manifest.webmanifest"></head><body class="relative min-h-screen bg-bg font-sans text-ink antialiased"><div id="__nuxt"></div><div id="teleports"></div><script>window.__NUXT__={};window.__NUXT__.config={public:{},app:{baseURL:"/",buildId:"9bc20942-bdb1-40fa-859b-b1c16104da2f",buildAssetsDir:"/_nuxt/",cdnURL:""}}</script><script type="application/json" data-nuxt-data="nuxt-app" data-ssr="false" id="__NUXT_DATA__">[{"prerenderedAt":1,"serverRendered":2},1791725332415,false]</script></body></html>
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{}]
@@ -1 +0,0 @@
self.__BUILD_MANIFEST=function(e,r,t){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},__routerFilterStatic:{numItems:5,errorRate:1e-4,numBits:96,numHashes:14,bitArray:[1,1,0,e,e,0,r,r,e,r,e,e,e,r,e,r,r,e,e,e,e,r,e,r,r,e,e,r,e,r,r,r,r,r,r,r,r,e,r,e,e,r,e,e,r,e,r,e,r,e,e,r,e,r,r,r,e,r,e,e,e,e,r,e,e,e,r,r,r,e,e,e,r,r,e,r,r,r,e,e,e,e,e,e,r,r,r,e,e,r,r,r,e,e,r,r]},__routerFilterDynamic:{numItems:r,errorRate:1e-4,numBits:r,numHashes:null,bitArray:[]},"/_error":["static/chunks/pages/_error-013f4188946cdd04.js"],sortedPages:["/_app","/_error"]}}(1,0,1e-4),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();
@@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
@@ -1 +0,0 @@
"use strict";(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[139],{5139:(e,t,l)=>{Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return s}});let u=l(4252),n=l(7876),a=u._(l(4232)),o=l(1033);async function r(e){let{Component:t,ctx:l}=e;return{pageProps:await (0,o.loadGetInitialProps)(t,l)}}class s extends a.default.Component{render(){let{Component:e,pageProps:t}=this.props;return(0,n.jsx)(e,{...t})}}s.origGetInitialProps=r,s.getInitialProps=r,("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[337,475,492,784],{4441:()=>{}},_=>{_.O(0,[441,255,358],()=>_(_.s=4441)),_N_E=_.O()}]);
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[177],{1255:e=>{e.exports={style:{fontFamily:"'vazirmatn', 'vazirmatn Fallback', Tahoma, system-ui, sans-serif"},className:"__className_b3ce59",variable:"__variable_b3ce59"}},3795:()=>{},4393:e=>{e.exports={style:{fontFamily:"'ibmPlexMono', 'ibmPlexMono Fallback', ui-monospace, monospace"},className:"__className_d30186",variable:"__variable_d30186"}},5776:(e,a,s)=>{Promise.resolve().then(s.t.bind(s,1255,23)),Promise.resolve().then(s.t.bind(s,4393,23)),Promise.resolve().then(s.t.bind(s,3795,23))}},e=>{e.O(0,[266,441,255,358],()=>e(e.s=5776)),_N_E=e.O()}]);
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[337,475,492,784],{4441:()=>{}},_=>{_.O(0,[441,255,358],()=>_(_.s=4441)),_N_E=_.O()}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[337,475,492,784],{4441:()=>{}},_=>{_.O(0,[441,255,358],()=>_(_.s=4441)),_N_E=_.O()}]);
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[337,475,492,784],{4441:()=>{}},_=>{_.O(0,[441,255,358],()=>_(_.s=4441)),_N_E=_.O()}]);
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[358],{7283:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,7150,23)),Promise.resolve().then(n.t.bind(n,1959,23)),Promise.resolve().then(n.t.bind(n,7989,23)),Promise.resolve().then(n.t.bind(n,3886,23)),Promise.resolve().then(n.t.bind(n,9766,23)),Promise.resolve().then(n.t.bind(n,5278,23)),Promise.resolve().then(n.t.bind(n,8924,23)),Promise.resolve().then(n.t.bind(n,4431,23)),Promise.resolve().then(n.bind(n,622))},9393:()=>{}},e=>{var s=s=>e(e.s=s);e.O(0,[441,255],()=>(s(1666),s(7283))),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[139,636],{326:(e,t,n)=>{(window.__NEXT_P=window.__NEXT_P||[]).push(["/_app",function(){return n(5139)}])},5139:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return s}});let u=n(4252),l=n(7876),a=u._(n(4232)),o=n(1033);async function r(e){let{Component:t,ctx:n}=e;return{pageProps:await (0,o.loadGetInitialProps)(t,n)}}class s extends a.default.Component{render(){let{Component:e,pageProps:t}=this.props;return(0,l.jsx)(e,{...t})}}s.origGetInitialProps=r,s.getInitialProps=r,("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)}},e=>{var t=t=>e(e.s=t);e.O(0,[593,792],()=>(t(326),t(6763))),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
(()=>{"use strict";var e={},t={};function r(o){var n=t[o];if(void 0!==n)return n.exports;var a=t[o]={exports:{}},i=!0;try{e[o](a,a.exports,r),i=!1}finally{i&&delete t[o]}return a.exports}r.m=e,(()=>{var e=[];r.O=(t,o,n,a)=>{if(o){a=a||0;for(var i=e.length;i>0&&e[i-1][2]>a;i--)e[i]=e[i-1];e[i]=[o,n,a];return}for(var u=1/0,i=0;i<e.length;i++){for(var[o,n,a]=e[i],l=!0,c=0;c<o.length;c++)(!1&a||u>=a)&&Object.keys(r.O).every(e=>r.O[e](o[c]))?o.splice(c--,1):(l=!1,a<u&&(u=a));if(l){e.splice(i--,1);var d=n();void 0!==d&&(t=d)}}return t}})(),r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},(()=>{var e,t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;r.t=function(o,n){if(1&n&&(o=this(o)),8&n||"object"==typeof o&&o&&(4&n&&o.__esModule||16&n&&"function"==typeof o.then))return o;var a=Object.create(null);r.r(a);var i={};e=e||[null,t({}),t([]),t(t)];for(var u=2&n&&o;"object"==typeof u&&!~e.indexOf(u);u=t(u))Object.getOwnPropertyNames(u).forEach(e=>i[e]=()=>o[e]);return i.default=()=>o,r.d(a,i),a}})(),r.d=(e,t)=>{for(var o in t)r.o(t,o)&&!r.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>"static/chunks/"+e+"."+({139:"7a5a8e93a21948c1",646:"f342b7cffc01feb0"})[e]+".js",r.miniCssF=e=>{},r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={},t="_N_E:";r.l=(o,n,a,i)=>{if(e[o])return void e[o].push(n);if(void 0!==a)for(var u,l,c=document.getElementsByTagName("script"),d=0;d<c.length;d++){var s=c[d];if(s.getAttribute("src")==o||s.getAttribute("data-webpack")==t+a){u=s;break}}u||(l=!0,(u=document.createElement("script")).charset="utf-8",u.timeout=120,r.nc&&u.setAttribute("nonce",r.nc),u.setAttribute("data-webpack",t+a),u.src=r.tu(o)),e[o]=[n];var f=(t,r)=>{u.onerror=u.onload=null,clearTimeout(p);var n=e[o];if(delete e[o],u.parentNode&&u.parentNode.removeChild(u),n&&n.forEach(e=>e(r)),t)return t(r)},p=setTimeout(f.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=f.bind(null,u.onerror),u.onload=f.bind(null,u.onload),l&&document.head.appendChild(u)}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:e=>e},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("nextjs#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="/_next/",(()=>{var e={68:0,266:0};r.f.j=(t,o)=>{var n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else if(/^(266|68)$/.test(t))e[t]=0;else{var a=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=a);var i=r.p+r.u(t),u=Error();r.l(i,o=>{if(r.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var a=o&&("load"===o.type?"missing":o.type),i=o&&o.target&&o.target.src;u.message="Loading chunk "+t+" failed.\n("+a+": "+i+")",u.name="ChunkLoadError",u.type=a,u.request=i,n[1](u)}},"chunk-"+t,t)}},r.O.j=t=>0===e[t];var t=(t,o)=>{var n,a,[i,u,l]=o,c=0;if(i.some(t=>0!==e[t])){for(n in u)r.o(u,n)&&(r.m[n]=u[n]);if(l)var d=l(r)}for(t&&t(o);c<i.length;c++)a=i[c],r.o(e,a)&&e[a]&&e[a][0](),e[a]=0;return r.O(d)},o=self.webpackChunk_N_E=self.webpackChunk_N_E||[];o.forEach(t.bind(null,0)),o.push=t.bind(null,o.push.bind(o))})()})();
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"id":"9bc20942-bdb1-40fa-859b-b1c16104da2f","timestamp":1791725267318}
@@ -0,0 +1 @@
{"id":"9bc20942-bdb1-40fa-859b-b1c16104da2f","timestamp":1791725267318,"prerendered":["/","/categories/respiratory-protection","/categories/safety-helmets","/categories/protective-gloves","/categories/hearing-protection","/__not-found","/categories/traffic-equipment","/categories/eye-protection","/categories/workwear","/categories/safety-footwear"]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725331531]
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725332414]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725332414]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725332414]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725332414]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725332413]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725332414]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725332415]
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
[{"data":1,"prerenderedAt":3},["ShallowReactive",2],{},1791725332413]
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='#FF5A00'/><path d='M50 20 L72 28 V48 C72 66 62 76 50 82 C38 76 28 66 28 48 V28 Z' fill='#0A0A0C'/><path d='M40 50 L47 57 L62 42' stroke='#FF5A00' stroke-width='7' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>

After

Width:  |  Height:  |  Size: 337 B

Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More