Author SHA1 Message Date
S. EsmaeiliandCursor cad1a8652e build: refresh admin dist with corrected site URL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 16:57:09 +03:30
S. EsmaeiliandCursor a8184ed59c fix(admin): point view-site links to http://imenpishgaman.ir
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 16:56:15 +03:30
S. EsmaeiliandClaude Sonnet 5.5 3dc5f4ae91 fix(compose): give internal services unique network aliases
On a shared Docker network (Dokploy) plain service names like web or api
also resolve to other stacks' containers, so the gateway balanced requests
across foreign containers and returned 502. Services reached by name now
carry an imen-* alias, used by the gateway upstreams and the service URLs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 08:28:04 +03:30
S. EsmaeiliandClaude Sonnet 5.5 f64b18aafb build: refresh production build output
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:42:16 +03:30
S. Esmaeili 975b8e546c build: update web production output and sync pre-built artifacts 2026-10-24 15:18:58 +03:30
S. EsmaeiliandClaude Haiku 5.5 10e453e4be build: ship pre-built API bundle; images copy output instead of installing
- API: `pnpm build` bundles dist/ into apps/api/.output (esbuild), with
  Linux musl native packages for sharp and argon2 kept alongside
- docker/api.Dockerfile copies that output; no pnpm install on the server
- add dist/migrate.js (prisma migrate deploy equivalent, same
  _prisma_migrations table and lock) so the Prisma CLI is not needed at runtime
- pnpm-workspace: supportedArchitectures for linux/x64/musl
- admin: apps/admin/.env.production sets VITE_SITE_URL for the production build
- drop NPM_REGISTRY / SITE_URL build args from compose and .env.example
- gitignore: anchor node_modules rules so pre-built node_modules are committed
- README: describe the pre-built deploy flow

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 14:41:31 +03:30
S. Esmaeili ee8258556d chore(compose): specify fixed image names for deploy loading 2026-10-24 13:53:30 +03:30
S. Esmaeili 2a45eef366 build: update production artifacts and simplify dockerfiles to use pre-built output 2026-10-24 13:30:24 +03:30
S. Esmaeili 7399812d0e build: include production build artifacts in repo 2026-10-24 10:11:05 +03:30
S. Esmaeili dfd8a82809 fix(docker): improve pnpm install resilience with fetch retries and fallback 2026-10-24 10:06:15 +03:30
S. Esmaeili a8a34075c7 chore(config): align default database port in .env.example with docker-compose 2026-10-24 09:53:40 +03:30
s.esmaeili bcb00dbe65 fix(web): resolve preloader freeze, dayjs ESM resolution, and missing icons 2026-10-24 00:51:53 +03:30
s.esmaeili 685a3e5206 fix(admin): add 8px padding to menu title next to icon badge 2026-10-23 21:20:32 +03:30
s.esmaeili c7884480c5 fix(admin): perfectly center sidebar menu icons inside badge 2026-10-23 21:17:51 +03:30
s.esmaeili cd5f123270 feat(web): replace preloader icon with company logo 2026-10-23 21:15:13 +03:30
s.esmaeili fa61a76444 feat: complete UI/UX improvements, vuesax icons, media server, and section management 2026-10-23 21:06:15 +03:30
S. EsmaeiliandCursor 49f30a96b1 feat: polish admin UI, phone login, and product details
Switch admin auth to mobile, restyle shell/login with Vuesax icons, and add product detail fields plus public product pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 17:18:17 +03:30
S. EsmaeiliandClaude Opus 5.5 789d224ea0 feat: monorepo with NestJS API, admin panel and DB-driven website
Restructure the repo into a pnpm + Turborepo monorepo:

- apps/web: Nuxt 4 site, now server-rendered and reading all content
  from the API; pages cached with SWR and purged on content changes
- apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived
  access tokens and rotating httpOnly refresh cookies, roles (ADMIN,
  EDITOR), catalog, editable site sections, media upload to S3-compatible
  storage (converted to WebP), contact messages, audit log
- apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for
  products, categories, site sections, messages, media and users
- packages/shared: Zod schemas and types shared by API and admin

Also:
- seed script that loads the original 8 categories / 89 products and
  section content
- Docker images for api (with a one-off migrate service), web, admin,
  and an nginx gateway for both domains
- dev and production compose files, daily database backup service
- API e2e tests

Removes the committed static out/ build and the old root Dockerfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:31:05 +03:30
1284 changed files with 395906 additions and 12449 deletions
+15 -6
View File
@@ -1,9 +1,18 @@
node_modules node_modules
.nuxt apps/*/node_modules
.output packages/*/node_modules
# **/dist
# **/.output
**/.nuxt
**/.turbo
**/*.tsbuildinfo
**/.env
**/.env.*
!**/.env.example
apps/api/src/generated
.git .git
*.md out
backups
محصولات
_dev.log _dev.log
.env* *.skill
npm-debug.log*
logo.png
+31
View File
@@ -0,0 +1,31 @@
# Production settings for docker-compose.yml — copy to .env and change every secret.
# Generate secrets with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# domains (TLS is terminated in front of the gateway: host nginx, Traefik or the CDN)
SITE_DOMAIN=imenpishgaman.ir
ADMIN_DOMAIN=admin.imenpishgaman.ir
# host port of the gateway container
HTTP_PORT=8080
# reverse proxies in front of the API: gateway (1) + host TLS proxy (1)
TRUST_PROXY=2
# set to false only if the admin is served over plain http
COOKIE_SECURE=true
# database
POSTGRES_USER=imen
POSTGRES_PASSWORD=change-me
POSTGRES_DB=imen
# object storage (MinIO container); bucket is created automatically
S3_BUCKET=imen-media
S3_ACCESS_KEY=imen
S3_SECRET_KEY=change-me-at-least-8-chars
# auth + cache purge secrets
JWT_SECRET=change-me-48-random-bytes
REVALIDATE_SECRET=change-me-too
# first admin user (created by: docker compose exec api node dist/seed/seed.js)
ADMIN_PHONE=09120000000
ADMIN_PASSWORD=change-me
ADMIN_NAME=مدیر سایت
+22 -5
View File
@@ -1,12 +1,18 @@
# dependencies # dependencies (build outputs keep theirs: apps/web/.output/server/node_modules, apps/api/.output/node_modules)
node_modules/ /node_modules/
/apps/*/node_modules/
/packages/*/node_modules/
# nuxt # build output
# dist/
# .output/
.nuxt/ .nuxt/
.output/
.data/ .data/
dist .turbo/
*.tsbuildinfo
# prisma generated client
apps/api/src/generated/
# misc # misc
.DS_Store .DS_Store
@@ -14,7 +20,18 @@ dist
# debug # debug
npm-debug.log* npm-debug.log*
pnpm-debug.log*
# local env files # local env files
.env .env
.env*.local .env*.local
!.env.example
# database dumps from the backup service
backups/
# local s3 mock storage
.s3data/
**/.s3data/
*.jpg
icons (1).json
+7
View File
@@ -0,0 +1,7 @@
out
dist
.output
.nuxt
pnpm-lock.yaml
apps/api/src/generated
محصولات
+3
View File
@@ -0,0 +1,3 @@
{
"printWidth": 100
}
-10
View File
@@ -1,10 +0,0 @@
# 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
COPY out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+147 -146
View File
@@ -1,149 +1,150 @@
# آریا ایمن — لندینگ (Nuxt) # ایمن پیشگامان — مونوریپو (سایت + API + پنل مدیریت)
لندینگ شرکت آریا ایمن، مهاجرت‌یافته از 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
```
## Build (تولید نسخه نهایی استاتیک)
```bash
npm run build # = nuxt generate
```
خروجی در پوشه `out/` ساخته می‌شود — فایل‌های HTML/CSS/JS کاملاً استاتیک که نیازی به سرور Node ندارند
(`index.html`، `404.html`، `robots.txt`، `sitemap.xml`، `manifest.webmanifest`، `_nuxt/`).
پیش‌نمایش محلی خروجی:
```bash
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` در سرور).
### روی سیستم توسعه (قبل از push)
```bash
npm install
npm run build
git add out Dockerfile nginx.conf docker-compose.yml
git commit -m "build: ..."
git push
```
### روی سرور پروداکشن
```bash
git pull
docker compose build --no-cache
docker compose up -d
```
پیش‌فرض `docker-compose.yml` پورت **8080** را به کانتینر map می‌کند؛ در reverse proxy (مثلاً Traefik یا Nginx روی host) به `http://127.0.0.1:8080` اشاره کنید، یا پورت را در compose به `80:80` تغییر دهید.
بدون compose:
```bash
docker build -t aria-imen-landing .
docker run -d --name aria-imen-landing -p 8080:80 --restart unless-stopped aria-imen-landing
```
تنظیمات Nginx داخل image مطابق خروجی استاتیک Nuxt است (`try_files`، کش immutable برای `/_nuxt/`، صفحه `404.html`).
## آپلود روی هاست (Static Hosting)
1. `npm run build` را اجرا کنید.
2. کل محتوای پوشه `out/` را (نه خود پوشه، بلکه فایل‌های داخلش) در ریشه هاست (public_html، یا مشابه آن در cPanel/Nginx) آپلود کنید.
3. سایت بدون نیاز به Node.js یا هیچ سرور اختصاصی کار می‌کند.
### نکات مهم هاست استاتیک (cPanel / Nginx / هاست ایرانی)
**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
```
**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;
```
ریدایرکت `location = /index.html { return 301 /; }` را نگذارید؛ با `index index.html` حلقهٔ ۳۰۱ روی `/` می‌سازد.
## پیش از دیپلوی واقعی — کارهایی که باید انجام دهید
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. **تصویر 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 با کلمه کلیدی اصلی | ✅ | `app/components/sections/Hero.vue` | | سایت | `apps/web` | Nuxt 4 (SSR + کش SWR)، Vue 3، Tailwind v4، Ant Design Vue، Pinia |
| سلسله‌مراتب H2/H3 منطقی، بدون پرش سطح | ✅ | `app/components/ui/SectionHead.vue` و همه سکشن‌ها | | API | `apps/api` | NestJS 12، Prisma 7، PostgreSQL، ذخیره‌ساز S3 (MinIO) |
| عنوان، title template و توضیحات | ✅ | `shared/config/site.ts`, `app/composables/useSiteSeo.ts` | | پنل مدیریت | `apps/admin` | Vue 3 + Vite، Ant Design Vue، Tailwind v4، Pinia، Vue Router |
| Canonical یکسان با sitemap (`https://ariaiman.ir/`) | ✅ | `app/pages/index.vue` | | کد مشترک | `packages/shared` | اسکیماهای Zod و تایپ‌ها (مشترک بین هر سه اپ) |
| Open Graph و Twitter Card **همراه با تصویر** (og:image/twitter:image) | ✅ | `app/composables/useSiteSeo.ts` |
| `lang="fa"` و `dir="rtl"` | ✅ | `nuxt.config.ts` | ابزار مونوریپو: **pnpm workspaces + Turborepo**.
| 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` | مرورگر ──► gateway (nginx)
| preload تصویر hero و فونت‌ها | ✅ | `app/pages/index.vue`, `nuxt.config.ts` | ├─ imenpishgaman.ir → web (Nuxt SSR، صفحات کش‌شده)
| alt توصیفی روی همه تصاویر محتوایی | ✅ | همه سکشن‌ها | ├─ admin.imenpishgaman.ir → admin (فایل‌های استاتیک)
| تمام متن در HTML بدون نیاز به اجرای JS | ✅ | خروجی `out/index.html` | ├─ /api/* → api (NestJS) ──► PostgreSQL
| فونت و تصاویر خودمیزبان (بدون CDN) | ✅ | `public/` | └─ /media/* → MinIO (تصاویر آپلودشده)
ذخیره در پنل ──► API ──► POST web:/_internal/revalidate ──► کش صفحات سایت پاک می‌شود
```
## ساختار پوشه‌ها
```
apps/
web/ سایت (همان طراحی قبلی؛ داده‌ها از API)
app/stores/site.ts محتوای سایت از GET /api/public/site (یک بار در هر رندر)
app/plugins/api.ts $api — در SSR آدرس داخلی API، در مرورگر /api
server/routes/ sitemap.xml (از دیتابیس)، robots.txt، manifest، _internal/revalidate
api/
prisma/schema.prisma مدل داده؛ migrations/ کنار آن
src/ ماژول‌ها: auth, users, catalog, media, sections, contact, public, dashboard
src/seed/ مقداردهی اولیه (محتوای فعلی سایت + ۸ دسته / ۸۹ محصول)
seed-assets/ تصاویر اولیه که seed در MinIO آپلود می‌کند
test/ تست‌های e2e
admin/
src/pages/ داشبورد، محصولات، دسته‌ها، بخش‌های سایت، پیام‌ها، رسانه، کاربران
src/sections/ فرم اختصاصی هر بخش سایت
packages/shared/src/ sections.ts (اسکیمای بخش‌ها)، catalog.ts، admin.ts، icons.ts
docker/ Dockerfile هر اپ و پیکربندی nginx
docker-compose.yml استقرار production
docker-compose.dev.yml PostgreSQL + MinIO برای توسعه
```
## راه‌اندازی محیط توسعه
پیش‌نیاز: Node.js 22.19 یا جدیدتر (۲۴ توصیه می‌شود)، Docker.
```bash
corepack enable pnpm # در ویندوز بدون دسترسی ادمین:
# corepack enable pnpm --install-directory "%APPDATA%\npm"
pnpm install
pnpm infra:up # PostgreSQL روی 5433 و MinIO روی 9000 (کنسول: 9001)
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
pnpm db:migrate # ساخت جدول‌ها
pnpm db:seed # محتوای اولیه + کاربر مدیر (ADMIN_PHONE / ADMIN_PASSWORD در apps/api/.env)
pnpm dev # هر سه اپ با هم
```
| آدرس | چیست |
|---|---|
| http://localhost:3000 | سایت |
| http://localhost:5173 | پنل مدیریت |
| http://localhost:4000/api | API |
| http://localhost:9001 | کنسول MinIO |
در توسعه، سرور Nuxt و Vite مسیرهای `/api` و `/media` را به API و MinIO پراکسی می‌کنند؛ در production همین کار را nginx انجام می‌دهد. به همین دلیل آدرس تصاویر همه‌جا به‌صورت `/media/...` ذخیره می‌شود.
### اسکریپت‌ها (از ریشه‌ی پروژه)
| دستور | کار |
|---|---|
| `pnpm dev` | اجرای همه‌ی اپ‌ها در حالت توسعه |
| `pnpm build` | build همه‌ی پکیج‌ها |
| `pnpm typecheck` | بررسی TypeScript همه‌ی پکیج‌ها |
| `pnpm test` | تست‌های e2e بکند (روی دیتابیس توسعه؛ داده‌های تست پاک می‌شوند) |
| `pnpm db:migrate` | ساخت/اعمال migration (`prisma migrate dev`) |
| `pnpm db:seed` | مقداردهی اولیه؛ اجرای دوباره چیزی را بازنویسی نمی‌کند |
| `pnpm db:studio` | Prisma Studio برای دیدن داده‌ها |
| `pnpm infra:up` / `infra:down` | روشن/خاموش کردن PostgreSQL و MinIO |
برای برگرداندن متن‌های سایت به محتوای اولیه: `SEED_OVERWRITE_SECTIONS=1 pnpm db:seed`.
## پنل مدیریت
- **محصولات**: افزودن/ویرایش، تصویر اصلی و گالری، توضیحات کامل، جدول مشخصات فنی، ویژگی‌های کلیدی، کاربردها، استانداردها، سایزها و مدل‌ها، برند و کشور سازنده، بسته‌بندی و حداقل سفارش، سئو، انتشار یا پیش‌نویس، کپی، مرتب‌سازی (با فیلتر یک دسته). هر محصول صفحه‌ی اختصاصی `/products/<اسلاگ>` دارد (گالری با بزرگ‌نمایی، مشخصات، محصولات مرتبط، دکمه‌ی درخواست قیمت که فرم تماس را با نام محصول پر می‌کند، JSON-LD نوع Product). بخش‌هایی که پر نشده‌اند در صفحه نمایش داده نمی‌شوند.
- **دسته‌بندی‌ها**: کاور، زیرگروه‌ها (فیلترهای صفحه‌ی دسته)، سئو، ترتیب نمایش.
- **بخش‌های سایت**: Hero، نوار متحرک، آمار، معرفی محصولات، درباره ما، خدمات، استانداردها، صنایع، نظرات، تماس، فوتر و تنظیمات سایت (تلفن، ایمیل، نشانی، ساعات کاری، شبکه‌های اجتماعی، سئوی پیش‌فرض).
- **پیام‌های تماس**: درخواست‌های فرم مشاوره‌ی سایت، با وضعیت جدید / خوانده‌شده / بایگانی.
- **کتابخانه رسانه**: همه‌ی تصاویر به‌صورت خودکار بهینه و به WebP تبدیل می‌شوند. تصویری که جایی استفاده شده قابل حذف نیست.
- **کاربران و نقش‌ها**: «مدیر کل» به همه‌چیز دسترسی دارد. «ویرایشگر» به‌جز کاربران و تنظیمات سایت، همه‌ی بخش‌ها را می‌بیند.
تغییرات پس از ذخیره، حداکثر ظرف چند ثانیه روی سایت دیده می‌شوند. اگر فراخوانی پاک‌کردن کش به هر دلیلی نرسد، صفحه‌ها در هر صورت هر ۶۰ ثانیه تازه می‌شوند.
**امنیت ورود**: access token کوتاه‌عمر (۱۵ دقیقه) فقط در حافظه‌ی مرورگر نگه داشته می‌شود و refresh token در کوکی httpOnly است. refresh token در هر استفاده عوض می‌شود. اگر یک توکن باطل‌شده دوباره استفاده شود، همه‌ی نشست‌های آن کاربر بسته می‌شوند. رمزها با argon2 ذخیره می‌شوند و تعداد تلاش برای ورود و ارسال فرم تماس محدود است.
## راهنمای توسعه
**افزودن فیلد به یک بخش سایت** (مثلاً یک متن جدید در Hero):
1. اسکیمای آن بخش را در `packages/shared/src/sections.ts` تغییر دهید. اگر فیلد اجباری است، مقدار پیش‌فرض بدهید (`text()`)، وگرنه داده‌های فعلی دیتابیس دیگر معتبر نیستند.
2. فیلد را به فرم همان بخش در `apps/admin/src/sections/` اضافه کنید.
3. در کامپوننت سایت (`apps/web/app/components/sections/`) از آن استفاده کنید.
4. مقدار اولیه را در `apps/api/src/seed/data/sections.ts` اضافه کنید.
یک اسکیمای Zod هم ورودی API را اعتبارسنجی می‌کند و هم فرم پنل را. برای همین پیام‌های خطا در هر دو یکسان و فارسی‌اند.
**تغییر مدل دیتابیس**: `apps/api/prisma/schema.prisma` را ویرایش کنید و `pnpm db:migrate` را اجرا کنید. اسم migration را می‌پرسد. فایل migration را هم commit کنید.
**آیکون‌ها**: آیکون‌های قابل‌انتخاب در پنل در `packages/shared/src/icons.ts` تعریف شده‌اند. برای افزودن آیکون جدید، آن را به همین لیست، به `apps/web/app/components/ui/DynamicIcon.vue` و به `apps/admin/src/components/icons.ts` اضافه کنید. این تکرار عمدی است تا فقط همین آیکون‌ها وارد باندل شوند.
**Ant Design و Tailwind**: استایل‌های Tailwind v4 داخل cascade layer هستند و CSS-in-JS آنت‌دیزاین بیرون از layer. بنابراین روی کامپوننت‌های antd کلاس ظاهری Tailwind نگذارید. در سایت، پالت رنگ در دو جا تعریف شده است: `@theme` در `main.css` و `palette` در `app/design/theme.ts`. هنگام تغییر، هر دو را هم‌گام کنید.
## استقرار (production)
```bash
cp .env.example .env # همه‌ی رمزها و دامنه‌ها را تنظیم کنید
docker compose up -d --build
docker compose exec api node dist/seed/seed.js # فقط بار اول
```
- کانتینر `gateway` روی پورت `HTTP_PORT` (پیش‌فرض 8080) گوش می‌دهد و هر دو دامنه (`SITE_DOMAIN` و `ADMIN_DOMAIN`) را سرو می‌کند. TLS را nginx یا Traefik روی سرور یا CDN جلوی آن تأمین می‌کند. هر دو دامنه باید به سرور اشاره کنند.
- سرویس یک‌باره‌ی `migrate` پیش از `api` اجرا می‌شود، migrationهای جدید را اعمال می‌کند و خارج می‌شود. این کار را `dist/migrate.js` انجام می‌دهد که هم‌ارز `prisma migrate deploy` است و از همان جدول `_prisma_migrations` استفاده می‌کند، پس Prisma CLI روی سرور لازم نیست.
- نام پروژه‌ی Compose ثابت و برابر `imen` است، تا با `docker-compose.dev.yml` (PostgreSQL و MinIO توسعه) هیچ کانتینر یا volume مشترکی نداشته باشد.
- سرویس `backup` هر روز یک dump از دیتابیس در `./backups` می‌سازد و dumpهای قدیمی‌تر از ۱۴ روز را پاک می‌کند. بازگردانی:
`docker compose exec -T postgres pg_restore -U imen -d imen --clean < backups/imen-YYYY-MM-DD.dump`
- از فایل‌های MinIO (volume `miniodata`) هم جداگانه بکاپ بگیرید.
**روی سرور چیزی نصب یا build نمی‌شود.** imageها فقط خروجی آماده‌ای را که در ریپو commit شده کپی می‌کنند، پس سرور به رجیستری npm نیازی ندارد. قبل از هر push روی سیستم توسعه `pnpm build` بگیرید و خروجی‌ها را هم commit کنید:
- `apps/web/.output` (همراه `server/node_modules`)، `apps/admin/dist` و `apps/api/.output`.
- `apps/api/.output` را `apps/api/scripts/bundle.mjs` می‌سازد. کد API و همه‌ی وابستگی‌های JS در آن bundle می‌شوند و فقط پکیج‌های native (`sharp` و `@node-rs/argon2`) با باینری Linux (Alpine، x64) کنارش کپی می‌شوند. این باینری‌ها را `supportedArchitectures` در `pnpm-workspace.yaml` موقع `pnpm install` دانلود می‌کند.
- آدرس سایت در لینک‌های پنل ادمین موقع build از `apps/admin/.env.production` خوانده می‌شود.
**Object Storage ابرآروان (یا هر S3 دیگر) به‌جای MinIO**: در سرویس `api`، مقادیر `S3_ENDPOINT`، `S3_BUCKET` و کلیدها را تنظیم کنید و `S3_ENSURE_BUCKET` را `false` بگذارید. `MEDIA_PUBLIC_URL` را روی آدرس عمومی باکت یا CDN بگذارید. بعد از آن به سرویس `minio` و مسیر `/media` در gateway نیازی نیست.
## کارهای باقی‌مانده و پیشنهادها
- **پر کردن جزئیات محصولات** (توضیحات کامل، مشخصات فنی، استانداردها و ...) از پنل؛ این فیلدها برای محصولات فعلی خالی‌اند و فعلاً صفحه‌ی محصول توضیح دسته را نشان می‌دهد.
- عکس برای ۱۷ محصول پوشاک کار و تجهیزات ترافیک (فعلاً کاور دسته نمایش داده می‌شود).
- **ایمپورت/اکسپورت اکسل** محصولات برای ورود دسته‌جمعی.
- **اعلان پیام تماس جدید** با ایمیل، پیامک یا ربات بله/تلگرام.
- پوشه‌ی `محصولات/` (عکس‌ها و فایل‌های Word خام) و فایل‌های `_dev.log` و `landing-nextjs-seo.skill` بهتر است از ریپو خارج شوند.
- لوگوی رسمی (`apps/web/public/images/logo.png`)، تصویر اختصاصی Open Graph با ابعاد ۱۲۰۰×۶۳۰ و آیکون‌های PNG برای PWA.
- CI روی سرور git (`pnpm typecheck && pnpm build && pnpm test`).
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { AnimatePresence } from "motion-v";
import Preloader from "./Preloader.vue";
import Cursor from "./Cursor.vue";
import Header from "./Header.vue";
import BackToTop from "./BackToTop.vue";
const ui = useUiStore();
</script>
<template>
<AnimatePresence>
<Preloader v-if="!ui.ready" @complete="ui.setReady" />
</AnimatePresence>
<Cursor />
<div class="noise" aria-hidden="true" />
<Header />
<slot />
<BackToTop />
</template>
-115
View File
@@ -1,115 +0,0 @@
<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>
-188
View File
@@ -1,188 +0,0 @@
<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>
-181
View File
@@ -1,181 +0,0 @@
<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>
-38
View File
@@ -1,38 +0,0 @@
<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
@@ -1,35 +0,0 @@
<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>
-42
View File
@@ -1,42 +0,0 @@
<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>
-28
View File
@@ -1,28 +0,0 @@
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,
});
}
-245
View File
@@ -1,245 +0,0 @@
<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
@@ -1,46 +0,0 @@
<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>
-24
View File
@@ -1,24 +0,0 @@
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";
},
},
});
+2
View File
@@ -0,0 +1,2 @@
# loaded by `vite build` (the pre-built dist/ the admin image serves); dev uses http://localhost:3000
VITE_SITE_URL=http://imenpishgaman.ir
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
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{b as e}from"./VuesaxIcon-UCFGkXBv.js";import{Et as t,It as n,Nt as r,Ot as i,Tt as a,_t as o,dt as s,i as c,kt as l,lt as u,ot as d,r as f,yt as p}from"./index-1U5l_fLl.js";var m=Symbol(`form-errors`),h=e=>Object.fromEntries(e.map(e=>[e.path,e.message]));function g(r,i){let a=n(structuredClone(i)),o=n({});t(m,o);function s(){let t=r.safeParse(a.value);return t.success?(o.value={},t.data):(o.value=h(e(t.error)),d.error(`لطفاً خطاهای فرم را برطرف کنید`),null)}function l(e){let t=f(e).issues;t?.length&&(o.value=h(t)),d.error(c(e))}function u(e){a.value=structuredClone(e),o.value={}}return{model:a,errors:o,validate:s,handleError:l,reset:u}}var _=o({__name:`FormField`,props:{path:{},label:{},hint:{},required:{type:Boolean}},setup(e){let t=e,n=p(m,null),o=u(()=>n?.value[t.path]);return(t,n)=>{let c=l(`a-form-item`);return a(),s(c,{label:e.label,required:e.required,"validate-status":o.value?`error`:void 0,help:o.value||e.hint},{default:r(()=>[i(t.$slots,`default`)]),_:3},8,[`label`,`required`,`validate-status`,`help`])}}});export{g as n,_ as t};
+1
View File
@@ -0,0 +1 @@
import{t as e,y as t}from"./VuesaxIcon-UCFGkXBv.js";import{Bt as n,Lt as r,Nt as i,Tt as a,_t as o,bt as s,dt as c,gt as l,jt as u,kt as d,pt as f,ut as p}from"./index-1U5l_fLl.js";var m={class:`flex items-center gap-2`},h={class:`flex size-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-[#ff5a00]`},g={key:1,class:`text-xs text-gray-400`},_={class:`flex items-center gap-2.5 py-0.5`,dir:`ltr`},v={class:`text-xs font-mono`},y=o({__name:`IconSelect`,props:s({allowClear:{type:Boolean}},{modelValue:{required:!0},modelModifiers:{}}),emits:[`update:modelValue`],setup(o){let s=u(o,`modelValue`),y=t.map(e=>({value:e,label:e}));return(t,u)=>{let b=d(`a-select`);return a(),f(`div`,m,[p(`span`,h,[s.value?(a(),c(e,{key:0,name:s.value,size:20},null,8,[`name`])):(a(),f(`span`,g,`---`))]),l(b,{value:s.value,"onUpdate:value":u[0]||=e=>s.value=e,class:`min-w-0 flex-1`,"show-search":``,"allow-clear":o.allowClear,options:r(y),placeholder:`انتخاب آیکون`},{option:i(({value:t})=>[p(`span`,_,[l(e,{name:t,size:18,class:`text-orange-600`},null,8,[`name`]),p(`span`,v,n(t),1)])]),_:1},8,[`value`,`allow-clear`,`options`])])}}});export{y as t};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{c as e,n as t,t as n}from"./VuesaxIcon-UCFGkXBv.js";import{It as r,Lt as i,Nt as a,Tt as o,_t as s,a as c,gt as l,ht as u,kt as d,mt as f,o as p,pt as m,st as h,t as g,ut as _}from"./index-1U5l_fLl.js";import"./logo-BuUh917n.js";import{n as v,t as y}from"./FormField-DbkYgDsg.js";var b={class:`login-page relative flex min-h-screen overflow-hidden text-[#edede6]`},x={class:`relative z-10 mx-auto grid w-full max-w-[1120px] flex-1 items-center gap-10 px-5 py-12 md:px-8 lg:grid-cols-12 lg:gap-16`},S={class:`login-fade-3 lg:col-span-6`},C={class:`login-panel w-full max-w-md border border-[#2a3d63]/70 bg-[#1a2744]/80 p-7 backdrop-blur-md md:p-8 lg:me-auto`},w=t(s({__name:`LoginPage`,setup(t){let s=g(),w=p(),T=c(),{model:E,validate:D,handleError:O}=v(e,{phone:``,password:``}),k=r(!1);async function A(){let e=D();if(e){k.value=!0;try{await s.login(e);let t=typeof T.query.redirect==`string`?T.query.redirect:`/`;await w.replace(t.startsWith(`/`)?t:`/`)}catch(e){O(e)}finally{k.value=!1}}}return(e,t)=>{let r=d(`a-input`),s=d(`a-input-password`),c=d(`a-button`),p=d(`a-form`);return o(),m(`div`,b,[t[5]||=f(`<div class="pointer-events-none absolute inset-0 bg-[#0b1224]" data-v-2d323d25></div><div class="login-grid pointer-events-none absolute inset-0 opacity-50" data-v-2d323d25></div><div class="pointer-events-none absolute -start-24 top-1/4 size-[420px] rounded-full bg-[#ff5a00]/15 blur-[120px]" data-v-2d323d25></div><div class="pointer-events-none absolute -end-16 bottom-0 size-[360px] rounded-full bg-[#ffb800]/10 blur-[100px]" data-v-2d323d25></div><div class="login-tape pointer-events-none absolute bottom-0 end-0 z-10 h-48 w-1.5 opacity-70" data-v-2d323d25></div><div class="login-noise pointer-events-none absolute inset-0 z-[5]" data-v-2d323d25></div>`,6),_(`div`,x,[_(`div`,S,[_(`div`,C,[t[3]||=_(`div`,{class:`mb-7 text-center`},[_(`div`,{class:`font-mono text-[10px] tracking-[0.28em] text-[#8e9ab0]`},`SIGN IN`),_(`h2`,{class:`mt-2 text-xl font-bold text-[#edede6]`},`ورود به پنل`),_(`p`,{class:`mt-1.5 text-sm text-[#8e9ab0]`},`شماره موبایل و رمز عبور خود را وارد کنید`)],-1),l(p,{class:`login-form`,layout:`vertical`,onSubmit:h(A,[`prevent`])},{default:a(()=>[l(y,{path:`phone`,label:`شماره موبایل`},{default:a(()=>[l(r,{value:i(E).phone,"onUpdate:value":t[0]||=e=>i(E).phone=e,size:`large`,class:`ltr-input`,autocomplete:`tel`,autofocus:``,inputmode:`tel`,placeholder:`09121234567`},{prefix:a(()=>[l(n,{name:`mobile`,size:16,class:`text-[#8e9ab0]`})]),_:1},8,[`value`])]),_:1}),l(y,{path:`password`,label:`رمز عبور`},{default:a(()=>[l(s,{value:i(E).password,"onUpdate:value":t[1]||=e=>i(E).password=e,size:`large`,class:`ltr-input`,autocomplete:`current-password`,placeholder:`••••••••`},{prefix:a(()=>[l(n,{name:`lock`,size:16,class:`text-[#8e9ab0]`})]),_:1},8,[`value`])]),_:1}),l(c,{type:`primary`,"html-type":`submit`,size:`large`,block:``,loading:k.value,class:`!mt-2 !h-12 !rounded-full !font-bold !shadow-[0_8px_32px_rgba(255,90,0,0.35)]`},{default:a(()=>[...t[2]||=[u(` ورود به پنل `,-1)]]),_:1},8,[`loading`])]),_:1})])]),t[4]||=f(`<div class="login-fade-1 lg:col-span-6" data-v-2d323d25><div class="inline-flex items-center gap-2.5 rounded-full border border-[#ff5a00]/30 bg-[#ff5a00]/10 px-4 py-2 backdrop-blur" data-v-2d323d25><span class="size-1.5 animate-pulse rounded-full bg-[#ff5a00]" data-v-2d323d25></span><span class="text-xs font-medium text-[#ff5a00]" data-v-2d323d25>پنل مدیریت ایمن پیشگامان</span></div><div class="mt-8 flex items-center gap-4" data-v-2d323d25><span class="flex size-14 items-center justify-center rounded-xl bg-white shadow-[0_0_28px_rgba(10,30,92,0.45)]" data-v-2d323d25><img src="/logo.png" alt="ایمن پیشگامان" class="size-9 object-contain" data-v-2d323d25></span><div class="leading-tight" data-v-2d323d25><div class="text-2xl font-black tracking-tight md:text-3xl" data-v-2d323d25>ایمن پیشگامان</div><div class="mt-1 font-mono text-[10px] tracking-[0.32em] text-[#8e9ab0]" data-v-2d323d25> IMEN PISHGAMAN CO. </div></div></div><h1 class="mt-8 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]" data-v-2d323d25> ورود امن <span class="mt-2 inline-block -rotate-1 rounded-lg bg-[#ffb800] px-3 py-1 text-[#0b1224] shadow-[0_10px_40px_rgba(255,184,0,0.22)]" data-v-2d323d25> به سامانه </span></h1><p class="mt-6 max-w-md text-sm leading-8 text-[#edede6]/70 md:text-base md:leading-9" data-v-2d323d25> مدیریت محصولات، محتوا و پیام‌های سایت — فقط برای کاربران مجاز. </p></div>`,1)])])}}}),[[`__scopeId`,`data-v-2d323d25`]]);export{w as default};
+1
View File
@@ -0,0 +1 @@
.login-grid[data-v-2d323d25]{background-image:linear-gradient(270deg,#edede60b 1px,#0000 1px),linear-gradient(#edede60b 1px,#0000 1px);background-size:72px 72px}.login-tape[data-v-2d323d25]{background-image:repeating-linear-gradient(45deg,#ffb800 0 10px,#0b1224 10px 20px)}.login-noise[data-v-2d323d25]{opacity:.045;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}.login-fade-1[data-v-2d323d25],.login-fade-3[data-v-2d323d25]{animation:.9s cubic-bezier(.22,1,.36,1) both login-fade-up-2d323d25}.login-fade-1[data-v-2d323d25]{animation-delay:.12s}.login-fade-3[data-v-2d323d25]{animation-delay:.28s}@keyframes login-fade-up-2d323d25{0%{opacity:0;transform:translateY(36px)}to{opacity:1;transform:translateY(0)}}.login-page[data-v-2d323d25]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}.login-panel[data-v-2d323d25] .ant-form-item-label>label{color:#edede6!important}.login-panel[data-v-2d323d25] .ant-input-affix-wrapper,.login-panel[data-v-2d323d25] .ant-input{color:#edede6!important;background:#152038!important;border-color:#2a3d63!important}.login-panel[data-v-2d323d25] .ant-input-affix-wrapper input.ant-input,.login-panel[data-v-2d323d25] .ant-input-affix-wrapper input{color:#edede6!important;background:0 0!important}.login-panel[data-v-2d323d25] .ant-input-affix-wrapper:hover,.login-panel[data-v-2d323d25] .ant-input-affix-wrapper-focused,.login-panel[data-v-2d323d25] .ant-input-affix-wrapper:focus-within{border-color:#ff5a00!important;box-shadow:0 0 0 2px #ff5a0026!important}.login-panel[data-v-2d323d25] .ant-input::placeholder,.login-panel[data-v-2d323d25] .ant-input-password-icon,.login-panel[data-v-2d323d25] .ant-input-suffix{color:#8e9ab0!important}.login-panel[data-v-2d323d25] input:-webkit-autofill{transition:background-color 99999s ease-in-out;-webkit-text-fill-color:#edede6!important;caret-color:#edede6!important;box-shadow:inset 0 0 0 1000px #152038!important}.login-panel[data-v-2d323d25] input:-webkit-autofill:hover{transition:background-color 99999s ease-in-out;-webkit-text-fill-color:#edede6!important;caret-color:#edede6!important;box-shadow:inset 0 0 0 1000px #152038!important}.login-panel[data-v-2d323d25] input:-webkit-autofill:focus{transition:background-color 99999s ease-in-out;-webkit-text-fill-color:#edede6!important;caret-color:#edede6!important;box-shadow:inset 0 0 0 1000px #152038!important}.login-panel[data-v-2d323d25] input:-webkit-autofill:active{transition:background-color 99999s ease-in-out;-webkit-text-fill-color:#edede6!important;caret-color:#edede6!important;box-shadow:inset 0 0 0 1000px #152038!important}.login-panel[data-v-2d323d25] .ant-form-item-explain-error{color:#ff7a2a}
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 @@
import{n as e,t}from"./VuesaxIcon-UCFGkXBv.js";import{Bt as n,Nt as r,Ot as i,Tt as a,_t as o,dt as s,ft as c,gt as l,kt as u,pt as d,ut as f}from"./index-1U5l_fLl.js";var p={class:`page-header`},m={class:`page-header__main`},h={class:`min-w-0`},g={class:`page-header__title`},_={key:0,class:`page-header__subtitle`},v={key:0,class:`page-header__actions`},y=e(o({__name:`PageHeader`,props:{title:{},subtitle:{},back:{}},setup(e){return(o,y)=>{let b=u(`RouterLink`);return a(),d(`div`,p,[f(`div`,m,[e.back?(a(),s(b,{key:0,to:e.back,class:`page-header__back`,title:`بازگشت`},{default:r(()=>[l(t,{name:`arrow-right`,size:18})]),_:1},8,[`to`])):c(``,!0),f(`div`,h,[f(`h1`,g,n(e.title),1),e.subtitle?(a(),d(`p`,_,n(e.subtitle),1)):c(``,!0)])]),o.$slots.default?(a(),d(`div`,v,[i(o.$slots,`default`,{},void 0,!0)])):c(``,!0)])}}}),[[`__scopeId`,`data-v-c0b72af8`]]);export{y as t};
+1
View File
@@ -0,0 +1 @@
.page-header[data-v-c0b72af8]{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px;display:flex;flex:none!important}.page-header__main[data-v-c0b72af8]{align-items:center;gap:8px;min-width:0;display:flex}.page-header__back[data-v-c0b72af8]{color:#475569;background:#f1f5f9;border-radius:8px;flex-shrink:0;justify-content:center;align-items:center;width:34px;height:34px;text-decoration:none;transition:all .15s;display:inline-flex}.page-header__back[data-v-c0b72af8]:hover{color:#0f172a;background:#e2e8f0}.page-header__title[data-v-c0b72af8]{color:#0f172a;margin:0;font-size:16px;font-weight:700;line-height:1.3}@media (width>=640px){.page-header__title[data-v-c0b72af8]{font-size:18px}}.page-header__subtitle[data-v-c0b72af8]{color:#94a3b8;margin:2px 0 0;font-size:12px;line-height:1.4}.page-header__actions[data-v-c0b72af8]{flex-wrap:wrap;align-items:center;gap:8px;display:flex}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.ap[data-v-7f365815]{flex-direction:column;height:100%;min-height:0;display:flex;overflow:hidden}.ap__head[data-v-7f365815]{background:#fff;border-bottom:1px solid #eef1f6;flex-shrink:0;padding:16px 16px 12px}.ap__body[data-v-7f365815]{flex:1;min-height:0;padding:16px;overflow:auto}.ap__body--locked[data-v-7f365815]{flex-direction:column;display:flex;overflow:hidden}@media (width<=1023px){.ap[data-v-7f365815]{height:auto;min-height:100%;overflow:visible}.ap__head[data-v-7f365815]{z-index:20;background:#fff;padding:10px 12px;position:sticky;top:52px;box-shadow:0 1px 3px #0f172a08}.ap__body[data-v-7f365815]{padding:12px;overflow:visible}.ap__body--locked[data-v-7f365815]{height:auto;overflow:visible}}.table-shell[data-v-a64d8be2],.table-shell[data-v-a64d8be2] .ant-table-wrapper,.table-shell[data-v-a64d8be2] .ant-spin-nested-loading,.table-shell[data-v-a64d8be2] .ant-spin-container{flex-direction:column;flex:1;height:100%;min-height:0;display:flex}.table-shell[data-v-a64d8be2] .ant-table{flex:1;min-height:0}.table-shell[data-v-a64d8be2] .ant-table-pagination{flex-shrink:0;margin:12px 0 0!important}.toolbar[data-v-a64d8be2]{flex-wrap:wrap;align-items:center;gap:12px;display:flex}.toolbar__start[data-v-a64d8be2]{flex:1;align-items:center;gap:16px;min-width:0;display:flex}.toolbar__search[data-v-a64d8be2]{flex:1;min-width:200px;max-width:360px}.toolbar__new[data-v-a64d8be2]{margin-inline-start:auto}.filter-panel[data-v-a64d8be2]{flex-direction:column;gap:10px;display:flex}.filter-panel__hint[data-v-a64d8be2]{color:#94a3b8;margin:0;font-size:12px;line-height:1.6}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{o as e,s as t}from"./VuesaxIcon-UCFGkXBv.js";import{Bt as n,It as r,Lt as i,Nt as a,Tt as o,_t as s,ct as c,gt as l,ht as u,kt as d,n as f,ot as p,pt as m,st as h,t as g,ut as _}from"./index-1U5l_fLl.js";import{t as v}from"./PageHeader-C9oHYjmE.js";import{n as y,t as b}from"./FormField-DbkYgDsg.js";var x={class:`grid max-w-4xl gap-4 md:gap-6 md:grid-cols-2`},S={class:`flex items-center gap-3.5 mb-4 pb-4 border-b border-gray-100`},C={class:`flex size-14 items-center justify-center rounded-2xl bg-gradient-to-tr from-[#ff5a00] to-amber-500 text-xl font-black text-white shadow-md shadow-orange-500/20`},w={class:`text-base font-bold text-gray-900`},T={class:`mt-0.5 text-xs font-mono text-gray-500`,dir:`ltr`},E={dir:`ltr`,class:`font-mono`},D=s({__name:`ProfilePage`,setup(s){let D=g(),{model:O,validate:k,handleError:A,reset:j}=y(t,{currentPassword:``,newPassword:``}),M=r(!1);async function N(){let e=k();if(e){M.value=!0;try{await f(`/admin/auth/change-password`,{method:`POST`,body:e}),p.success(`رمز عبور تغییر کرد`),j({currentPassword:``,newPassword:``})}catch(e){A(e)}finally{M.value=!1}}}return(t,r)=>{let s=d(`a-descriptions-item`),f=d(`a-tag`),p=d(`a-descriptions`),g=d(`a-card`),y=d(`a-input-password`),k=d(`a-button`),A=d(`a-form`);return o(),m(c,null,[l(v,{title:`حساب کاربری`,subtitle:`مشخصات فردی، نقش دسترسی و تغییر رمز عبور ورود به سامانه`}),_(`div`,x,[l(g,{title:`مشخصات کاربری`},{default:a(()=>[_(`div`,S,[_(`div`,C,n(i(D).user?.name?.slice(0,1)||`U`),1),_(`div`,null,[_(`div`,w,n(i(D).user?.name),1),_(`div`,T,n(i(D).user?.phone),1)])]),l(p,{column:1,size:`small`},{default:a(()=>[l(s,{label:`نام`},{default:a(()=>[u(n(i(D).user?.name),1)]),_:1}),l(s,{label:`موبایل`},{default:a(()=>[_(`span`,E,n(i(D).user?.phone),1)]),_:1}),l(s,{label:`نقش دسترسی`},{default:a(()=>[l(f,{color:i(D).user?.role===`ADMIN`?`orange`:`blue`},{default:a(()=>[u(n(i(D).user&&i(e)[i(D).user.role]),1)]),_:1},8,[`color`])]),_:1})]),_:1})]),_:1}),l(g,{title:`تغییر رمز عبور`},{default:a(()=>[l(A,{layout:`vertical`,onSubmit:h(N,[`prevent`])},{default:a(()=>[l(b,{path:`currentPassword`,label:`رمز فعلی`,required:``},{default:a(()=>[l(y,{value:i(O).currentPassword,"onUpdate:value":r[0]||=e=>i(O).currentPassword=e,class:`ltr-input h-10`,autocomplete:`current-password`},null,8,[`value`])]),_:1}),l(b,{path:`newPassword`,label:`رمز جدید`,hint:`حداقل ۸ کاراکتر`,required:``},{default:a(()=>[l(y,{value:i(O).newPassword,"onUpdate:value":r[1]||=e=>i(O).newPassword=e,class:`ltr-input h-10`,autocomplete:`new-password`},null,8,[`value`])]),_:1}),l(k,{type:`primary`,"html-type":`submit`,loading:M.value,class:`w-full md:w-auto h-10 !font-bold`},{default:a(()=>[...r[2]||=[u(` تغییر رمز ورود `,-1)]]),_:1},8,[`loading`])]),_:1})]),_:1})])],64)}}});export{D as default};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{d as e,f as t}from"./VuesaxIcon-UCFGkXBv.js";import{Bt as n,Ct as r,Dt as i,Ft as a,It as o,Lt as s,Nt as c,Rt as l,Tt as u,_t as d,ct as f,dt as p,ft as m,gt as h,ht as g,i as _,kt as v,n as y,o as b,ot as x,pt as S,t as C,ut as w}from"./index-1U5l_fLl.js";import{a as T}from"./format-BwvWvVfG.js";import{t as E}from"./PageHeader-C9oHYjmE.js";var D={class:`grid gap-4 sm:grid-cols-2 xl:grid-cols-3`},O={class:`flex items-center justify-between gap-2`},k=[`onClick`],A={class:`font-bold text-gray-800 group-hover:text-orange-600 transition-colors`},j={key:0,class:`flex items-center flex-shrink-0`},M=[`onClick`],N=[`onClick`],P={key:0},F=d({__name:`SectionsPage`,setup(d){let F=b(),I=C(),L=e.filter(e=>e!==`settings`||I.isAdmin),R=o(!0),z=a({hero:!0,tape:!0,stats:!0,productsIntro:!0,about:!0,services:!0,standards:!0,industries:!0,testimonials:!0,contact:!0,footer:!0}),B=a({}),V=a({});function H(e){return z[e]!==!1}function U(e){return B[e]??null}async function W(){R.value=!0;try{let e=await y(`/admin/sections`);for(let t of e)z[t.key]=t.enabled!==!1,B[t.key]=t.updatedAt}catch(e){x.error(_(e))}finally{R.value=!1}}async function G(e,n){if(e===`settings`)return;let r=z[e]!==!1;z[e]=n,V[e]=!0;try{let r=await y(`/admin/sections/${e}/visibility`,{method:`PATCH`,body:{enabled:n}});z[e]=r.enabled!==!1,B[e]=r.updatedAt,x.success(`وضعیت «${t[e].title}» به ${n?`نمایش`:`عدم نمایش`} تغییر یافت`)}catch(t){z[e]=r,x.error(_(t))}finally{V[e]=!1}}function K(e){F.push({name:`section-edit`,params:{key:e}})}return r(()=>{W()}),(e,r)=>{let a=v(`a-tag`),o=v(`a-switch`),d=v(`a-card`),_=v(`a-spin`);return u(),S(f,null,[h(E,{title:`بخش‌های سایت`,subtitle:`متن‌ها، تصاویر و فهرست‌های صفحه اصلی؛ تنظیم وضعیت نمایش و ویرایش محتوا`}),h(_,{spinning:R.value},{default:c(()=>[w(`div`,D,[(u(!0),S(f,null,i(s(L),e=>(u(),S(`div`,{key:e,class:`block group`},[h(d,{hoverable:``,class:l([`h-full flex flex-col justify-between transition-all`,{"opacity-75 bg-gray-50/60":e!==`settings`&&!H(e)}])},{default:c(()=>[w(`div`,null,[w(`div`,O,[w(`div`,{class:`flex items-center gap-2 cursor-pointer flex-1`,onClick:t=>K(e)},[w(`span`,A,n(s(t)[e].title),1),e===`settings`?(u(),p(a,{key:0,color:`blue`},{default:c(()=>[...r[0]||=[g(`تنظیمات پایه`,-1)]]),_:1})):H(e)?m(``,!0):(u(),p(a,{key:1,color:`error`},{default:c(()=>[...r[1]||=[g(`مخفی`,-1)]]),_:1}))],8,k),e===`settings`?m(``,!0):(u(),S(`div`,j,[h(o,{checked:H(e),loading:V[e],"checked-children":`نمایش`,"un-checked-children":`مخفی`,onChange:t=>G(e,!!t)},null,8,[`checked`,`loading`,`onChange`])]))]),w(`p`,{class:`mt-2 text-sm leading-6 text-gray-500 cursor-pointer`,onClick:t=>K(e)},n(s(t)[e].desc),9,M)]),w(`div`,{class:`mt-4 flex items-center justify-between border-t border-gray-100 pt-3 text-xs text-gray-400 cursor-pointer`,onClick:t=>K(e)},[r[2]||=w(`span`,{class:`text-primary font-medium group-hover:underline`},`ویرایش بخش ←`,-1),U(e)?(u(),S(`span`,P,`آخرین تغییر: `+n(s(T)(U(e))),1)):m(``,!0)],8,N)]),_:2},1032,[`class`])]))),128))])]),_:1},8,[`spinning`])],64)}}});export{F as default};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.vx-icon[data-v-9a78c900]{color:inherit;vertical-align:-.15em;flex-shrink:0;justify-content:center;align-items:center;line-height:0;display:inline-flex}.vx-icon[data-v-9a78c900] svg{width:100%;height:100%;display:block}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
var e=new Intl.DateTimeFormat(`fa-IR-u-ca-persian`,{year:`numeric`,month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`}),t=t=>t?e.format(new Date(t)):`—`,n=e=>e<1048576?`${(e/1024).toLocaleString(`fa-IR`,{maximumFractionDigits:0})} کیلوبایت`:`${(e/1024/1024).toLocaleString(`fa-IR`,{maximumFractionDigits:1})} مگابایت`,r=e=>e.toLocaleString(`fa-IR`),i=`http://imenpishgaman.ir`,a={create:`ایجاد`,update:`ویرایش`,delete:`حذف`,upload:`آپلود`,login:`ورود`,publish:`انتشار`,unpublish:`عدم انتشار`,duplicate:`کپی`,reorder:`مرتب‌سازی`,"change-password":`تغییر رمز`},o={product:`محصول`,category:`دسته`,section:`بخش سایت`,media:`رسانه`,user:`کاربر`,message:`پیام`},s=(e,t)=>`${a[e]??e} ${o[t]??t}`;export{t as a,n as i,s as n,r,i as t};
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 @@
var e=`/logo.png`;export{e as t};
+1
View File
@@ -0,0 +1 @@
var e=e=>e.toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,80);export{e as t};
View File

Before

Width:  |  Height:  |  Size: 337 B

After

Width:  |  Height:  |  Size: 337 B

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>پنل مدیریت ایمن پیشگامان</title>
<script type="module" crossorigin src="/assets/index-1U5l_fLl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DjFpBvci.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
View File

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>پنل مدیریت ایمن پیشگامان</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@imen/admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --port 5173",
"typecheck": "vue-tsc -b"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@imen/shared": "workspace:*",
"ant-design-vue": "^4.2.6",
"lucide-vue-next": "^0.475.0",
"ofetch": "^1.5.1",
"pinia": "^4.0.3",
"vue": "^3.5.43",
"vue-router": "^5.3.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24.0.0",
"@vitejs/plugin-vue": "^6.0.10",
"@vue/tsconfig": "^0.8.1",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"vite": "^8.0.0",
"vue-tsc": "^3.3.12"
}
}

Before

Width:  |  Height:  |  Size: 337 B

After

Width:  |  Height:  |  Size: 337 B

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { theme } from "ant-design-vue";
import faIR from "ant-design-vue/es/locale/fa_IR";
const antdTheme = {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#ff5a00",
colorInfo: "#ff5a00",
colorLink: "#e04f00",
fontFamily: '"IRANSans", Tahoma, sans-serif',
borderRadius: 8,
},
};
</script>
<template>
<a-config-provider :theme="antdTheme" direction="rtl" :locale="faIR">
<RouterView />
</a-config-provider>
</template>
+59
View File
@@ -0,0 +1,59 @@
import { FetchError, type FetchOptions, ofetch } from "ofetch";
import type { ValidationIssue } from "@imen/shared";
/** Raw client without auth handling (login / refresh / logout use it directly) */
export const rawApi = ofetch.create({ baseURL: "/api", credentials: "include" });
type AuthHooks = {
token: () => string;
refresh: () => Promise<boolean>;
onExpired: () => void;
};
let hooks: AuthHooks | null = null;
/** Wired by the auth store, which owns the access token */
export const setAuthHooks = (h: AuthHooks) => {
hooks = h;
};
/**
* Authenticated request. On 401 it refreshes the access token once (using the httpOnly
* refresh cookie) and retries; if that fails the session is over.
*/
export async function api<T>(url: string, opts: FetchOptions<"json"> = {}, retried = false): Promise<T> {
const token = hooks?.token();
try {
return await rawApi<T>(url, {
...opts,
headers: { ...(opts.headers as Record<string, string>), ...(token && { Authorization: `Bearer ${token}` }) },
});
} catch (e) {
if (e instanceof FetchError && e.status === 401 && !retried && hooks) {
if (await hooks.refresh()) return api<T>(url, opts, true);
hooks.onExpired();
}
throw e;
}
}
interface ApiErrorBody {
message?: string | string[];
issues?: ValidationIssue[];
}
export const errorBody = (e: unknown): ApiErrorBody =>
e instanceof FetchError ? ((e.data as ApiErrorBody | undefined) ?? {}) : {};
/** Human-readable (Persian) error message for notifications */
export function errorMessage(e: unknown): string {
if (e instanceof FetchError) {
if (!e.status) return "ارتباط با سرور برقرار نشد";
const msg = errorBody(e).message;
if (Array.isArray(msg)) return msg.join("، ");
if (msg) return msg;
if (e.status === 413) return "حجم فایل بیش از حد مجاز است";
if (e.status === 429) return "تعداد درخواست‌ها زیاد است؛ کمی بعد تلاش کنید";
return `خطای سرور (${e.status})`;
}
return "خطای ناشناخته";
}
+84
View File
@@ -0,0 +1,84 @@
import type { MediaDto, ProductAttribute, SectionKey, SiteSections } from "@imen/shared";
/** Shapes returned by the admin endpoints of the API */
export interface AdminCategory {
id: number;
slug: string;
code: string;
name: string;
nameEn: string;
shortDesc: string;
fullDesc: string;
icon: string;
coverImageId: number | null;
coverImage: MediaDto | null;
sortOrder: number;
isPublished: boolean;
seoTitle: string;
seoDescription: string;
productCount: number;
groupCount: number;
}
export interface AdminProductGroup {
id: number;
slug: string;
name: string;
desc: string;
sortOrder: number;
productCount: number;
}
export interface AdminCategoryDetail extends Omit<AdminCategory, "productCount" | "groupCount"> {
groups: AdminProductGroup[];
}
export interface AdminProduct {
id: number;
categoryId: number;
groupId: number | null;
code: string;
slug: string;
name: string;
nameEn: string;
desc: string;
specs: string[];
variants: string[];
details: string;
brand: string;
origin: string;
sizes: string[];
standards: string[];
features: string[];
applications: string[];
attributes: ProductAttribute[] | null;
packaging: string;
minOrder: string;
seoTitle: string;
seoDescription: string;
imageId: number | null;
image: MediaDto | null;
sortOrder: number;
isPublished: boolean;
category: { id: number; name: string };
group: { id: number; name: string } | null;
updatedAt: string;
}
export interface AdminProductDetail extends Omit<AdminProduct, "category" | "group"> {
gallery: MediaDto[];
}
export interface SectionResponse<K extends SectionKey = SectionKey> {
key: K;
data: SiteSections[K];
updatedAt: string;
}
export interface AdminSectionSummary {
key: SectionKey;
updatedAt: string | null;
enabled: boolean;
}
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
/**
* Page shell inside the white panel:
* - optional sticky head (toolbar / filters)
* - body: scrolls by default, or locks height when `lockBody` (table fills + scrolls internally)
*/
defineProps<{ lockBody?: boolean }>();
</script>
<template>
<div class="ap">
<header v-if="$slots.head" class="ap__head">
<slot name="head" />
</header>
<div class="ap__body" :class="{ 'ap__body--locked': lockBody }">
<slot />
</div>
</div>
</template>
<style scoped>
.ap {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.ap__head {
flex-shrink: 0;
padding: 16px 16px 12px;
border-bottom: 1px solid #eef1f6;
background: #ffffff;
}
.ap__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
}
.ap__body--locked {
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (max-width: 1023px) {
.ap {
height: auto;
min-height: 100%;
overflow: visible;
}
.ap__head {
position: sticky;
top: 52px;
z-index: 20;
padding: 10px 12px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03);
}
.ap__body {
padding: 12px;
overflow: visible;
}
.ap__body--locked {
overflow: visible;
height: auto;
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { FORM_ERRORS } from "@/composables/useZodForm";
/** a-form-item that shows the zod error for `path` (provided by useZodForm) */
const props = defineProps<{ path: string; label?: string; hint?: string; required?: boolean }>();
const errors = inject(FORM_ERRORS, null);
const error = computed(() => errors?.value[props.path]);
</script>
<template>
<a-form-item
:label="label"
:required="required"
:validate-status="error ? 'error' : undefined"
:help="error || hint"
>
<slot />
</a-form-item>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { RECOMMENDED_ICONS } from "@imen/shared";
import VuesaxIcon from "./VuesaxIcon.vue";
const value = defineModel<string>({ required: true });
defineProps<{ allowClear?: boolean }>();
const options = RECOMMENDED_ICONS.map((name) => ({ value: name, label: name }));
</script>
<template>
<div class="flex items-center gap-2">
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-[#ff5a00]">
<VuesaxIcon v-if="value" :name="value" :size="20" />
<span v-else class="text-xs text-gray-400">---</span>
</span>
<a-select
v-model:value="value"
class="min-w-0 flex-1"
show-search
:allow-clear="allowClear"
:options="options"
placeholder="انتخاب آیکون"
>
<template #option="{ value: name }">
<span class="flex items-center gap-2.5 py-0.5" dir="ltr">
<VuesaxIcon :name="name" :size="18" class="text-orange-600" />
<span class="text-xs font-mono">{{ name }}</span>
</span>
</template>
</a-select>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { MediaDto } from "@imen/shared";
import { ref } from "vue";
import MediaPicker from "./MediaPicker.vue";
/** Image preview with "choose" / "remove" buttons; the parent decides what to store */
defineProps<{ url?: string | null; clearable?: boolean }>();
const emit = defineEmits<{ pick: [media: MediaDto]; clear: [] }>();
const open = ref(false);
</script>
<template>
<div class="flex items-start gap-4">
<div
class="flex size-28 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-dashed border-gray-300 bg-gray-50"
>
<img v-if="url" :src="url" alt="" class="size-full object-contain" />
<VuesaxIcon v-else name="gallery" :size="32" class="text-gray-300" />
</div>
<a-space direction="vertical">
<a-button @click="open = true">
<template #icon><VuesaxIcon name="gallery" :size="16" /></template>
{{ url ? "تغییر تصویر" : "انتخاب یا آپلود تصویر" }}
</a-button>
<a-button v-if="url && clearable" danger @click="emit('clear')">
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
حذف تصویر
</a-button>
</a-space>
<MediaPicker v-model:open="open" @select="emit('pick', $event)" />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ImageRef } from "@imen/shared";
import FormField from "./FormField.vue";
import ImageField from "./ImageField.vue";
/** Edits an { url, alt } image stored inside section content */
const image = defineModel<ImageRef>({ required: true });
defineProps<{ path: string; label: string }>();
</script>
<template>
<FormField :path="`${path}.url`" :label="label" required>
<ImageField
:url="image.url"
@pick="(m) => (image = { url: m.url, alt: image.alt || m.alt })"
/>
</FormField>
<FormField :path="`${path}.alt`" label="متن جایگزین تصویر (alt)" hint="برای سئو و دسترسی‌پذیری؛ تصویر را در یک جمله توصیف کنید">
<a-input v-model:value="image.alt" />
</FormField>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { ref, watch } from "vue";
/** Textarea where every non-empty line is one list item (for longer items than tags allow) */
const items = defineModel<string[]>({ required: true });
defineProps<{ placeholder?: string; rows?: number }>();
const parse = (text: string) =>
text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
// keep the raw text locally so blank lines and trailing newlines survive while typing
const text = ref(items.value.join("\n"));
watch(items, (v) => {
if (parse(text.value).join("\n") !== v.join("\n")) text.value = v.join("\n");
});
const onInput = () => {
items.value = parse(text.value);
};
</script>
<template>
<a-textarea
v-model:value="text"
:placeholder="placeholder"
:auto-size="{ minRows: rows ?? 3, maxRows: 14 }"
@change="onInput"
/>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts" generic="T">
import VuesaxIcon from "@/components/VuesaxIcon.vue";
/** Editable list: add, remove and reorder items; each item is rendered by the default slot */
const items = defineModel<T[]>({ required: true });
const props = withDefaults(
defineProps<{
create: () => T;
addLabel?: string;
max?: number;
/** title shown on each card, e.g. (item, i) => item.title */
itemTitle?: (item: T, index: number) => string;
}>(),
{ addLabel: "افزودن مورد", max: 50 },
);
const move = (i: number, d: -1 | 1) => {
const list = [...items.value];
const [it] = list.splice(i, 1);
list.splice(i + d, 0, it as T);
items.value = list;
};
const remove = (i: number) => {
items.value = items.value.filter((_, j) => j !== i);
};
const add = () => {
items.value = [...items.value, props.create()];
};
</script>
<template>
<div class="space-y-3">
<div
v-for="(item, i) in items"
:key="i"
class="rounded-lg border border-gray-200 bg-white p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-600">
{{ (i + 1).toLocaleString("fa-IR") }}. {{ itemTitle?.(item, i) ?? "" }}
</span>
<a-space :size="4">
<a-button size="small" :disabled="i === 0" title="بالا" @click="move(i, -1)">
<template #icon><VuesaxIcon name="arrow-up" :size="16" /></template>
</a-button>
<a-button size="small" :disabled="i === items.length - 1" title="پایین" @click="move(i, 1)">
<template #icon><VuesaxIcon name="arrow-down" :size="16" /></template>
</a-button>
<a-popconfirm title="این مورد حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(i)">
<a-button size="small" danger title="حذف">
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
</a-button>
</a-popconfirm>
</a-space>
</div>
<slot :item="item" :index="i" />
</div>
<a-button type="dashed" block :disabled="items.length >= max" @click="add">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
{{ addLabel }}
</a-button>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { ref, watch } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
/** Modal to upload a new image or pick one from the media library */
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ select: [media: MediaDto] }>();
const tab = ref<"library" | "upload">("library");
const items = ref<MediaDto[]>([]);
const total = ref(0);
const page = ref(1);
const q = ref("");
const loading = ref(false);
const uploading = ref(false);
const PAGE_SIZE = 24;
async function load() {
loading.value = true;
try {
const res = await api<Paginated<MediaDto>>("/admin/media", {
query: { page: page.value, pageSize: PAGE_SIZE, q: q.value || undefined },
});
items.value = res.items;
total.value = res.total;
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
watch(open, (v) => v && load());
function choose(m: MediaDto) {
emit("select", m);
open.value = false;
}
/** Upload, then return the new file straight to the caller */
async function upload(file: File) {
uploading.value = true;
try {
const body = new FormData();
body.append("file", file);
const media = await api<MediaDto>("/admin/media", { method: "POST", body });
void message.success("تصویر آپلود شد");
choose(media);
} catch (e) {
void message.error(errorMessage(e));
} finally {
uploading.value = false;
}
return false; // stop a-upload's own request
}
</script>
<template>
<a-modal v-model:open="open" title="انتخاب تصویر" :footer="null" width="900px" destroy-on-close>
<a-tabs v-model:active-key="tab">
<a-tab-pane key="library" tab="کتابخانه رسانه">
<a-input-search
v-model:value="q"
placeholder="جستجو در نام فایل یا متن جایگزین"
allow-clear
class="mb-4"
@search="() => { page = 1; load(); }"
/>
<a-spin :spinning="loading">
<a-empty v-if="!items.length && !loading" description="تصویری پیدا نشد" />
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6">
<button
v-for="m in items"
:key="m.id"
type="button"
class="group overflow-hidden rounded-lg border border-gray-200 bg-gray-50 text-start transition hover:border-[#ff5a00] focus:border-[#ff5a00] focus:outline-none"
:title="m.originalName"
@click="choose(m)"
>
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain" loading="lazy" />
<div class="truncate px-1.5 py-1 text-[11px] text-gray-500" dir="ltr">{{ m.originalName }}</div>
</button>
</div>
</a-spin>
<div class="mt-4 flex justify-center">
<a-pagination
v-model:current="page"
:total="total"
:page-size="PAGE_SIZE"
:show-size-changer="false"
@change="load"
/>
</div>
</a-tab-pane>
<a-tab-pane key="upload" tab="آپلود تصویر جدید">
<a-spin :spinning="uploading">
<a-upload-dragger
accept="image/jpeg,image/png,image/webp,image/avif,image/gif"
:show-upload-list="false"
:before-upload="upload"
>
<p class="ant-upload-drag-icon"><VuesaxIcon name="document-upload" :size="40" /></p>
<p class="ant-upload-text">تصویر را اینجا رها کنید یا کلیک کنید</p>
<p class="ant-upload-hint">
JPG، PNG، WebP، AVIF یا GIF — حداکثر ۱۵ مگابایت. تصویر به‌صورت خودکار بهینه و به WebP تبدیل می‌شود.
</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import VuesaxIcon from "@/components/VuesaxIcon.vue";
defineProps<{ title: string; subtitle?: string; back?: string }>();
</script>
<template>
<div class="page-header">
<div class="page-header__main">
<RouterLink v-if="back" :to="back" class="page-header__back" title="بازگشت">
<VuesaxIcon name="arrow-right" :size="18" />
</RouterLink>
<div class="min-w-0">
<h1 class="page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
</div>
</div>
<div v-if="$slots.default" class="page-header__actions">
<slot />
</div>
</div>
</template>
<style scoped>
.page-header {
flex: 0 0 auto !important;
flex-shrink: 0 !important;
flex-grow: 0 !important;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.page-header__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.page-header__back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: #f1f5f9;
color: #475569;
text-decoration: none;
transition: all 0.15s ease;
flex-shrink: 0;
}
.page-header__back:hover {
background: #e2e8f0;
color: #0f172a;
}
.page-header__title {
margin: 0;
font-size: 16px;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
}
@media (min-width: 640px) {
.page-header__title {
font-size: 18px;
}
}
.page-header__subtitle {
margin: 2px 0 0;
font-size: 12px;
color: #94a3b8;
line-height: 1.4;
}
.page-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SectionHeadData } from "@imen/shared";
import FormField from "./FormField.vue";
/** Tag / title / highlight / description fields shared by most sections */
const head = defineModel<SectionHeadData>({ required: true });
withDefaults(defineProps<{ path?: string; withHighlight?: boolean; withDesc?: boolean }>(), {
path: "head",
withHighlight: true,
withDesc: true,
});
</script>
<template>
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`${path}.tag`" label="برچسب بالای عنوان" hint="مثلاً «۰۱ — محصولات»" required>
<a-input v-model:value="head.tag" />
</FormField>
<FormField :path="`${path}.title`" label="عنوان" hint="برای شکستن خط، Enter بزنید" required>
<a-textarea v-model:value="head.title" :auto-size="{ minRows: 2, maxRows: 4 }" />
</FormField>
<FormField v-if="withHighlight" :path="`${path}.highlight`" label="خط رنگی انتهای عنوان (اختیاری)">
<a-input v-model:value="head.highlight" />
</FormField>
<FormField v-if="withDesc" :path="`${path}.desc`" label="توضیح زیر عنوان" class="md:col-span-2">
<a-textarea v-model:value="head.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from "vue";
import { resolveVuesaxIcon } from "@imen/shared";
import icons from "@/icons/vuesax-linear.json";
const props = withDefaults(
defineProps<{
/** Short name (`add`), alias (`ShieldCheck`, `Phone`), or full key (`vuesax-linear:add`) */
name: string;
size?: number | string;
}>(),
{ size: 18 },
);
const pack = icons as Record<string, string>;
const svgHtml = computed(() => {
const resolved = resolveVuesaxIcon(props.name || "");
const key = resolved.includes(":") ? resolved : `vuesax-linear:${resolved}`;
const raw = pack[key] || pack[`vuesax-linear:${resolved.toLowerCase()}`] || pack["vuesax-linear:shield-tick"];
if (!raw) return "";
return raw
.replace(/\swidth="24"/, "")
.replace(/\sheight="24"/, "")
.replace(/stroke="#[A-Fa-f0-9]{3,8}"/g, 'stroke="currentColor"')
.replace(/fill="(?!none)#[A-Fa-f0-9]{3,8}"/g, 'fill="currentColor"');
});
const box = computed(() =>
typeof props.size === "number" ? `${props.size}px` : props.size,
);
</script>
<template>
<span
class="anticon vx-icon"
:style="{ width: box, height: box }"
:aria-hidden="true"
v-html="svgHtml"
/>
</template>
<style scoped>
.vx-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
line-height: 0;
color: inherit;
vertical-align: -0.15em;
}
.vx-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
</style>
+2
View File
@@ -0,0 +1,2 @@
export { ICON_ALIASES, ICON_NAMES, RECOMMENDED_ICONS, resolveVuesaxIcon } from "@imen/shared";
export type { IconName } from "@imen/shared";
+5
View File
@@ -0,0 +1,5 @@
import { h, type VNode } from "vue";
import VuesaxIcon from "./VuesaxIcon.vue";
/** Render a Vuesax linear icon (for Ant menu `icon` factories, etc.) */
export const vx = (name: string, size = 18): VNode => h(VuesaxIcon, { name, size });
+47
View File
@@ -0,0 +1,47 @@
import { toIssues, type ValidationIssue } from "@imen/shared";
import { message } from "ant-design-vue";
import { type InjectionKey, provide, type Ref, ref } from "vue";
import type { z } from "zod";
import { errorBody, errorMessage } from "@/api/http";
/** Field errors keyed by dot path ("items.2.title"), read by <FormField> */
export const FORM_ERRORS: InjectionKey<Ref<Record<string, string>>> = Symbol("form-errors");
const toMap = (issues: ValidationIssue[]) =>
Object.fromEntries(issues.map((i) => [i.path, i.message]));
/**
* Form state validated with the same zod schema the API uses.
* The model is typed as the schema output (every field present) since forms start fully filled.
* `validate()` returns parsed data or null (and marks the invalid fields).
*/
export function useZodForm<S extends z.ZodType>(schema: S, initial: z.output<S>) {
const model = ref(structuredClone(initial)) as Ref<z.output<S>>;
const errors = ref<Record<string, string>>({});
provide(FORM_ERRORS, errors);
function validate(): z.output<S> | null {
const result = schema.safeParse(model.value);
if (result.success) {
errors.value = {};
return result.data;
}
errors.value = toMap(toIssues(result.error));
void message.error("لطفاً خطاهای فرم را برطرف کنید");
return null;
}
/** Shows an API error; validation issues from the server are attached to their fields */
function handleError(e: unknown) {
const issues = errorBody(e).issues;
if (issues?.length) errors.value = toMap(issues);
void message.error(errorMessage(e));
}
function reset(value: z.output<S>) {
model.value = structuredClone(value);
errors.value = {};
}
return { model, errors, validate, handleError, reset };
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
import Antd from "ant-design-vue";
import { createPinia } from "pinia";
import { createApp } from "vue";
import { z } from "zod";
import App from "./App.vue";
import { router } from "./router";
import "./style.css";
// Persian default validation messages (custom messages in @imen/shared still win)
z.config(z.locales.fa());
createApp(App).use(createPinia()).use(router).use(Antd).mount("#app");
+256
View File
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { AdminCategory } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { faNumber, SITE_URL } from "@/utils/format";
const auth = useAuthStore();
const rows = ref<AdminCategory[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
rows.value = await api<AdminCategory[]>("/admin/categories");
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
async function move(index: number, d: -1 | 1) {
const list = [...rows.value];
const [row] = list.splice(index, 1);
list.splice(index + d, 0, row!);
rows.value = list;
try {
await api("/admin/categories/reorder", { method: "POST", body: { ids: list.map((r) => r.id) } });
} catch (e) {
void message.error(errorMessage(e));
await load();
}
}
async function remove(row: AdminCategory) {
try {
await api(`/admin/categories/${row.id}`, { method: "DELETE" });
void message.success("دسته حذف شد");
await load();
} catch (e) {
void message.error(errorMessage(e));
}
}
const columns = [
{ title: "تصویر", key: "cover", width: 80 },
{ title: "نام", key: "name" },
{ title: "کد", dataIndex: "code", width: 100 },
{ title: "محصولات", key: "products", width: 100 },
{ title: "وضعیت", key: "status", width: 110 },
{ title: "ترتیب", key: "order", width: 100 },
{ title: "", key: "actions", width: 170 },
];
onMounted(load);
</script>
<template>
<PageHeader title="دسته‌بندی‌ها" subtitle="ترتیب دسته‌ها همان ترتیب نمایش در سایت است">
<RouterLink :to="{ name: 'category-new' }">
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
دسته جدید
</a-button>
</RouterLink>
</PageHeader>
<a-card class="!hidden lg:!block" :body-style="{ padding: 0 }">
<a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 800 }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'cover'">
<img v-if="record.coverImage" :src="record.coverImage.url" alt="" class="size-12 rounded object-cover" />
</template>
<template v-else-if="column.key === 'name'">
<RouterLink :to="{ name: 'category-edit', params: { id: record.id } }" class="font-bold">
{{ record.name }}
</RouterLink>
<div class="text-xs text-gray-400" dir="ltr">{{ record.slug }}</div>
</template>
<template v-else-if="column.key === 'products'">
<RouterLink :to="{ name: 'products', query: { categoryId: record.id } }">
{{ faNumber(record.productCount) }}
</RouterLink>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.isPublished ? 'green' : 'default'">
{{ record.isPublished ? "منتشرشده" : "مخفی" }}
</a-tag>
</template>
<template v-else-if="column.key === 'order'">
<a-space :size="4">
<a-button size="small" :disabled="index === 0" @click="move(index, -1)">
<template #icon><VuesaxIcon name="arrow-up" :size="16" /></template>
</a-button>
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
<template #icon><VuesaxIcon name="arrow-down" :size="16" /></template>
</a-button>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<RouterLink :to="{ name: 'category-edit', params: { id: record.id } }">
<a-button size="small"><template #icon><VuesaxIcon name="edit-2" :size="16" /></template></a-button>
</RouterLink>
<a :href="`${SITE_URL}/categories/${record.slug}`" target="_blank" rel="noopener">
<a-button size="small" title="مشاهده در سایت"><template #icon><VuesaxIcon name="export" :size="16" /></template></a-button>
</a>
<a-popconfirm
v-if="auth.isAdmin"
title="این دسته حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as AdminCategory)"
>
<a-button size="small" danger :disabled="record.productCount > 0" :title="record.productCount > 0 ? 'ابتدا محصولات این دسته را حذف یا جابه‌جا کنید' : 'حذف'">
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- Mobile Category Cards View (Mobile Only) -->
<div class="mobile-categories-view block lg:!hidden pb-24">
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="دسته‌بندی ثبت نشده است" class="py-12" />
<div v-else class="space-y-3">
<div
v-for="(record, index) in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3.5 shadow-2xs transition"
>
<!-- Top Row: Cover + Info -->
<div class="flex items-start gap-3">
<img
v-if="record.coverImage"
:src="record.coverImage.url"
alt=""
class="size-14 rounded-lg object-cover shrink-0 border border-slate-100 bg-slate-50"
/>
<div v-else class="size-14 rounded-lg bg-slate-100 shrink-0 flex items-center justify-center text-slate-300">
<VuesaxIcon name="category" :size="22" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<RouterLink
:to="{ name: 'category-edit', params: { id: record.id } }"
class="font-bold text-[14px] text-slate-800 hover:text-orange-600 line-clamp-1"
>
{{ record.name }}
</RouterLink>
<span class="text-[10px] font-mono text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded shrink-0" dir="ltr">
{{ record.code }}
</span>
</div>
<div class="flex items-center gap-2 mt-1.5 text-xs">
<RouterLink
:to="{ name: 'products', query: { categoryId: record.id } }"
class="inline-flex items-center gap-1 text-orange-600 font-bold bg-orange-50 px-2 py-0.5 rounded-full"
>
{{ faNumber(record.productCount) }} محصول
</RouterLink>
<a-tag :color="record.isPublished ? 'green' : 'default'" class="!m-0 !text-[10px]">
{{ record.isPublished ? "منتشرشده" : "مخفی" }}
</a-tag>
</div>
</div>
</div>
<!-- Bottom Row: Reorder Controls + Actions -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-3">
<!-- Touch Reorder Buttons -->
<div class="flex items-center gap-1">
<span class="text-[11px] text-slate-400 me-1">ترتیب:</span>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === 0"
@click="move(index, -1)"
>
<VuesaxIcon name="arrow-up" :size="16" />
</button>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === rows.length - 1"
@click="move(index, 1)"
>
<VuesaxIcon name="arrow-down" :size="16" />
</button>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-1.5">
<RouterLink :to="{ name: 'category-edit', params: { id: record.id } }">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="ویرایش"
>
<VuesaxIcon name="edit-2" :size="16" />
</button>
</RouterLink>
<a :href="`${SITE_URL}/categories/${record.slug}`" target="_blank" rel="noopener">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="مشاهده در سایت"
>
<VuesaxIcon name="export" :size="16" />
</button>
</a>
<a-popconfirm
v-if="auth.isAdmin"
title="این دسته حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as AdminCategory)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 disabled:opacity-30 disabled:pointer-events-none active:bg-rose-200 transition"
:disabled="record.productCount > 0"
:title="record.productCount > 0 ? 'ابتدا محصولات این دسته را حذف کنید' : 'حذف'"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</a-spin>
<!-- Mobile Floating Action Button (FAB) for New Category -->
<RouterLink
:to="{ name: 'category-new' }"
class="fixed start-4 bottom-[72px] z-30 flex items-center gap-2 px-4 py-2.5 rounded-full bg-linear-to-r from-[#ff5a00] to-[#ff7a29] text-white font-bold text-xs shadow-lg shadow-orange-500/30 active:scale-95 transition"
>
<VuesaxIcon name="add" :size="18" />
<span>دسته جدید</span>
</RouterLink>
</div>
</template>
+268
View File
@@ -0,0 +1,268 @@
<script setup lang="ts">
import { categoryInputSchema, type MediaDto } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { z } from "zod";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { AdminCategoryDetail } from "@/api/types";
import FormField from "@/components/FormField.vue";
import IconSelect from "@/components/IconSelect.vue";
import ImageField from "@/components/ImageField.vue";
import ListEditor from "@/components/ListEditor.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { toSlug } from "@/utils/slug";
type Form = z.output<typeof categoryInputSchema>;
type Group = Form["groups"][number];
const route = useRoute();
const router = useRouter();
const id = computed(() => (route.params.id ? Number(route.params.id) : null));
const empty: Form = {
slug: "",
code: "",
name: "",
nameEn: "",
shortDesc: "",
fullDesc: "",
icon: "",
coverImageId: null,
isPublished: true,
seoTitle: "",
seoDescription: "",
groups: [],
};
const { model, validate, handleError, reset } = useZodForm(categoryInputSchema, empty);
const cover = ref<MediaDto | null>(null);
/** product count per existing group id, to warn before deleting a group */
const groupProducts = ref<Record<number, number>>({});
const loading = ref(false);
const saving = ref(false);
async function load() {
if (!id.value) return;
loading.value = true;
try {
const c = await api<AdminCategoryDetail>(`/admin/categories/${id.value}`);
reset({
slug: c.slug,
code: c.code,
name: c.name,
nameEn: c.nameEn,
shortDesc: c.shortDesc,
fullDesc: c.fullDesc,
icon: c.icon,
coverImageId: c.coverImageId,
isPublished: c.isPublished,
seoTitle: c.seoTitle,
seoDescription: c.seoDescription,
groups: c.groups.map((g) => ({ id: g.id, slug: g.slug, name: g.name, desc: g.desc })),
});
cover.value = c.coverImage;
groupProducts.value = Object.fromEntries(c.groups.map((g) => [g.id, g.productCount]));
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
function pickCover(m: MediaDto) {
cover.value = m;
model.value.coverImageId = m.id;
}
async function save() {
const data = validate();
if (!data) return;
saving.value = true;
try {
const saved = await api<AdminCategoryDetail>(
id.value ? `/admin/categories/${id.value}` : "/admin/categories",
{ method: id.value ? "PUT" : "POST", body: data },
);
void message.success("دسته ذخیره شد");
if (!id.value) await router.replace({ name: "category-edit", params: { id: saved.id } });
await load();
} catch (e) {
handleError(e);
} finally {
saving.value = false;
}
}
const newGroup = (): Group => ({ slug: "", name: "", desc: "" });
const groupTitle = (g: Group) => {
const count = g.id ? groupProducts.value[g.id] : 0;
return count ? `${g.name} (${count} محصول)` : g.name;
};
const mobileTab = ref<"main" | "groups" | "media" | "seo">("main");
onMounted(load);
</script>
<template>
<PageHeader :title="id ? `ویرایش دسته: ${model.name}` : 'دسته جدید'" back="/categories">
<a-button type="primary" :loading="saving" @click="save">
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</PageHeader>
<!-- Mobile Segmented Workflow Tabs -->
<div class="lg:hidden mb-4 overflow-x-auto pb-1 scrollbar-none">
<div class="flex items-center gap-1.5 min-w-max bg-gray-100 p-1 rounded-xl">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'main' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'main'"
>
اطلاعات اصلی
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all flex items-center gap-1"
:class="mobileTab === 'groups' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'groups'"
>
<span>زیرگروه‌ها</span>
<span class="rounded-full bg-gray-200 px-1.5 py-0.2 text-[10px]">{{ model.groups.length }}</span>
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'media' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'media'"
>
کاور و آیکون
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'seo' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'seo'"
>
سئو و انتشار
</button>
</div>
</div>
<a-spin :spinning="loading">
<a-form layout="vertical" class="pb-20 lg:pb-0" @submit.prevent="save">
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<a-card title="اطلاعات اصلی" :class="mobileTab !== 'main' && 'max-lg:hidden'">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام دسته" required>
<a-input v-model:value="model.name" />
</FormField>
<FormField path="nameEn" label="نام انگلیسی" hint="زیر نام دسته نمایش داده می‌شود">
<a-input v-model:value="model.nameEn" class="ltr-input" />
</FormField>
<FormField path="slug" label="اسلاگ (آدرس صفحه)" :hint="`آدرس: /categories/${model.slug || '...'}`" required>
<a-input v-model:value="model.slug" class="ltr-input">
<template #suffix>
<a class="text-xs" @click="model.slug = toSlug(model.nameEn)">ساخت از نام انگلیسی</a>
</template>
</a-input>
</FormField>
<FormField path="code" label="کد دسته" required>
<a-input v-model:value="model.code" class="ltr-input" placeholder="IP-C09" />
</FormField>
<FormField path="shortDesc" label="توضیح کوتاه" class="md:col-span-2">
<a-textarea v-model:value="model.shortDesc" :auto-size="{ minRows: 2, maxRows: 4 }" />
</FormField>
<FormField path="fullDesc" label="توضیح کامل (بالای صفحه دسته)" class="md:col-span-2">
<a-textarea v-model:value="model.fullDesc" :auto-size="{ minRows: 3, maxRows: 8 }" />
</FormField>
</div>
</a-card>
<a-card title="زیرگروه‌ها" :body-style="{ background: '#fafafa' }" :class="mobileTab !== 'groups' && 'max-lg:hidden'">
<p class="mb-4 text-sm text-gray-500">
زیرگروه‌ها در صفحه دسته به‌صورت فیلتر نمایش داده می‌شوند. با حذف یک زیرگروه، محصولات آن بدون زیرگروه می‌مانند.
</p>
<ListEditor
v-model="model.groups"
:create="newGroup"
add-label="افزودن زیرگروه"
:item-title="groupTitle"
>
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`groups.${index}.name`" label="نام" required>
<a-input v-model:value="item.name" />
</FormField>
<FormField :path="`groups.${index}.slug`" label="اسلاگ" required>
<a-input v-model:value="item.slug" class="ltr-input" />
</FormField>
<FormField :path="`groups.${index}.desc`" label="توضیح" class="md:col-span-2">
<a-textarea v-model:value="item.desc" :auto-size="{ minRows: 1, maxRows: 4 }" />
</FormField>
</div>
</template>
</ListEditor>
</a-card>
</div>
<div class="space-y-6">
<a-card title="انتشار" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "مخفی" }}</span>
</FormField>
</a-card>
<a-card title="تصویر کاور" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="coverImageId">
<ImageField :url="cover?.url" clearable @pick="pickCover" @clear="cover = null; model.coverImageId = null" />
</FormField>
</a-card>
<a-card title="آیکون" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="icon">
<IconSelect v-model="model.icon" allow-clear />
</FormField>
</a-card>
<a-card title="سئو" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام دسته">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField>
<FormField path="seoDescription" label="توضیحات متا" hint="خالی = توضیح کامل">
<a-textarea v-model:value="model.seoDescription" :maxlength="300" show-count :auto-size="{ minRows: 3 }" />
</FormField>
</a-card>
</div>
</div>
</a-form>
</a-spin>
<!-- Mobile Sticky Bottom Action Bar -->
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-2.5 backdrop-blur-md shadow-lg flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<a-switch v-model:checked="model.isPublished" size="small" />
<span class="text-xs font-medium text-gray-700">{{ model.isPublished ? "انتشار" : "پیش‌نویس" }}</span>
</div>
<div class="flex items-center gap-2">
<a
v-if="model.slug"
:href="`/categories/${model.slug}`"
target="_blank"
rel="noopener"
>
<a-button size="middle" class="!px-3" title="مشاهده در سایت">
<template #icon><VuesaxIcon name="eye" :size="16" /></template>
</a-button>
</a>
<a-button type="primary" size="middle" class="!font-bold !px-5" :loading="saving" @click="save">
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره تغییرات
</a-button>
</div>
</div>
</template>
+341
View File
@@ -0,0 +1,341 @@
<script setup lang="ts">
import { type DashboardDto, MESSAGE_STATUS_LABELS } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { activityLabel, faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const data = ref<DashboardDto | null>(null);
const loading = ref(true);
const maxCategoryCount = computed(() => {
if (!data.value?.categoryStats?.length) return 1;
return Math.max(...data.value.categoryStats.map((c) => c.count), 1);
});
const maxTrendCount = computed(() => {
if (!data.value?.trend?.length) return 1;
return Math.max(...data.value.trend.map((t) => t.messages), 1);
});
const sectionPercent = computed(() => {
if (!data.value?.sectionsSummary?.total) return 100;
return Math.round((data.value.sectionsSummary.visible / data.value.sectionsSummary.total) * 100);
});
onMounted(async () => {
loading.value = true;
try {
data.value = await api<DashboardDto>("/admin/dashboard");
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
});
</script>
<template>
<PageHeader title="داشبورد مدیریت" subtitle="نمای کلی آمار، نمودارها، پیام‌ها و فعالیت‌های اخیر سامانه">
<div class="flex items-center gap-2">
<RouterLink :to="{ name: 'sections' }">
<a-button>
<template #icon><VuesaxIcon name="row-vertical" :size="16" /></template>
مدیریت بخش‌ها
</a-button>
</RouterLink>
<RouterLink :to="{ name: 'product-new' }">
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</div>
</PageHeader>
<a-spin :spinning="loading">
<!-- 1. Top Statistics Row -->
<div class="grid grid-cols-2 gap-3 lg:gap-4 lg:grid-cols-5">
<RouterLink :to="{ name: 'products' }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="کل محصولات" :value="faNumber(data?.products ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-orange-50 text-orange-600">
<VuesaxIcon name="bag-2" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
{{ faNumber((data?.products ?? 0) - (data?.draftProducts ?? 0)) }} محصول فعال روی سایت
</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'products', query: { status: 'draft' } }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="پیش‌نویس‌ها" :value="faNumber(data?.draftProducts ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-amber-50 text-amber-600">
<VuesaxIcon name="document-text" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">نیاز به بازبینی و انتشار</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'categories' }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="دسته‌بندی‌ها" :value="faNumber(data?.categories ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-blue-50 text-blue-600">
<VuesaxIcon name="category" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">شاخه‌های تجهیزات ایمنی</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'messages', query: { status: 'NEW' } }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic
title="پیام‌های جدید"
:value="faNumber(data?.newMessages ?? 0)"
:value-style="data?.newMessages ? { color: '#ff5a00', fontWeight: 'bold' } : undefined"
/>
<div
class="flex size-10 items-center justify-center rounded-xl"
:class="data?.newMessages ? 'bg-orange-100 text-orange-600 animate-pulse' : 'bg-gray-100 text-gray-500'"
>
<VuesaxIcon name="sms" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
از مجموع {{ faNumber(data?.messageBreakdown?.total ?? 0) }} پیام
</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'media' }" class="max-lg:col-span-2 active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="رسانه‌ها و تصاویر" :value="faNumber(data?.media ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-purple-50 text-purple-600">
<VuesaxIcon name="gallery" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">تصاویر بهینه‌شده WebP</div>
</a-card>
</RouterLink>
</div>
<!-- 2. Quick Actions & Site Health Section -->
<div class="mt-6 grid gap-6 lg:grid-cols-12">
<!-- Quick Action Buttons -->
<a-card title="دسترسی‌های سریع" class="lg:col-span-8">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<RouterLink :to="{ name: 'product-new' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-orange-200 hover:bg-orange-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-orange-600 shadow-sm">
<VuesaxIcon name="add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">افزودن محصول</span>
<span class="mt-1 text-[11px] text-gray-400">کالای ایمنی جدید</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'category-new' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-blue-200 hover:bg-blue-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-blue-600 shadow-sm">
<VuesaxIcon name="folder-add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">دسته‌بندی جدید</span>
<span class="mt-1 text-[11px] text-gray-400">گروه محصولات</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'sections' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-emerald-200 hover:bg-emerald-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-emerald-600 shadow-sm">
<VuesaxIcon name="row-vertical" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">بخش‌های سایت</span>
<span class="mt-1 text-[11px] text-gray-400">نمایش یا مخفی‌سازی</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'media' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-purple-200 hover:bg-purple-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-purple-600 shadow-sm">
<VuesaxIcon name="gallery-add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">آپلود رسانه</span>
<span class="mt-1 text-[11px] text-gray-400">تصاویر و اسناد</span>
</div>
</RouterLink>
</div>
</a-card>
<!-- Section Visibility Health Card -->
<a-card title="وضعیت بخش‌های صفحه اصلی" class="lg:col-span-4">
<template #extra>
<RouterLink :to="{ name: 'sections' }" class="text-xs text-orange-600 hover:underline">
مدیریت
</RouterLink>
</template>
<div class="flex items-center gap-4">
<a-progress
type="circle"
:percent="sectionPercent"
:size="72"
:stroke-color="{ '0%': '#ff8a3d', '100%': '#ff5a00' }"
/>
<div class="space-y-1.5 text-xs">
<div class="font-bold text-gray-800">
{{ faNumber(data?.sectionsSummary?.visible ?? 0) }} بخش از {{ faNumber(data?.sectionsSummary?.total ?? 12) }} فعال
</div>
<div class="text-gray-500">
<span v-if="(data?.sectionsSummary?.hidden ?? 0) === 0" class="text-emerald-600 font-medium">
تمامی بخش‌های سایت در حال نمایش هستند
</span>
<span v-else class="text-amber-600 font-medium">
{{ faNumber(data?.sectionsSummary?.hidden ?? 0) }} بخش غیرفعال / مخفی
</span>
</div>
<div class="text-[11px] text-gray-400">
تغییر وضعیت آنی از منوی بخش‌های سایت
</div>
</div>
</div>
</a-card>
</div>
<!-- 3. Charts Row (نمودارهای تحلیلی) -->
<div class="mt-6 grid gap-6 lg:grid-cols-12">
<!-- Chart 1: Products distribution per category -->
<a-card title="نمودار توزیع محصولات بر اساس دسته‌بندی" class="lg:col-span-7">
<div v-if="!data?.categoryStats?.length" class="py-8">
<a-empty description="داده‌ای موجود نیست" />
</div>
<div v-else class="space-y-3.5 pt-1">
<div v-for="cat in data.categoryStats" :key="cat.id" class="space-y-1">
<div class="flex items-center justify-between text-xs font-medium">
<span class="text-gray-700">{{ cat.name }}</span>
<span class="font-mono text-gray-500">{{ faNumber(cat.count) }} محصول</span>
</div>
<a-progress
:percent="Math.round((cat.count / maxCategoryCount) * 100)"
:show-info="false"
:stroke-color="{ '0%': '#ffd0b5', '100%': '#ff5a00' }"
size="small"
/>
</div>
</div>
</a-card>
<!-- Chart 2: Message Status Breakdown & Trend -->
<a-card title="تحلیل پیام‌های تماس دریافتی" class="lg:col-span-5">
<!-- Message Status Chips -->
<div class="grid grid-cols-3 gap-2 text-center">
<div class="rounded-lg bg-orange-50 p-2.5">
<div class="text-lg font-black text-orange-600">{{ faNumber(data?.messageBreakdown?.new ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">جدید</div>
</div>
<div class="rounded-lg bg-blue-50 p-2.5">
<div class="text-lg font-black text-blue-600">{{ faNumber(data?.messageBreakdown?.read ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">خوانده‌شده</div>
</div>
<div class="rounded-lg bg-gray-50 p-2.5">
<div class="text-lg font-black text-gray-600">{{ faNumber(data?.messageBreakdown?.archived ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">بایگانی</div>
</div>
</div>
<!-- 7-Day Trend Bar Chart -->
<div class="mt-5 border-t border-gray-100 pt-4">
<div class="mb-3 flex items-center justify-between text-xs">
<span class="font-bold text-gray-700">روند پیام‌ها در ۷ روز اخیر</span>
<span class="text-gray-400">نمودار روزانه</span>
</div>
<div class="flex h-28 items-end justify-between gap-1.5 sm:gap-2 pt-4">
<div
v-for="day in data?.trend ?? []"
:key="day.date"
class="flex flex-1 flex-col items-center gap-1"
>
<span v-if="day.messages > 0" class="text-[9px] font-bold text-orange-600 mb-0.5">
{{ faNumber(day.messages) }}
</span>
<span v-else class="text-[9px] text-transparent mb-0.5 select-none">-</span>
<a-tooltip :title="`${day.dayName}: ${faNumber(day.messages)} پیام`">
<div class="relative flex w-full justify-center">
<div
class="w-full max-w-[24px] rounded-t-md transition-all duration-300"
:class="day.messages > 0 ? 'bg-orange-500 hover:bg-orange-600' : 'bg-gray-100'"
:style="{
height: `${Math.max(Math.round((day.messages / maxTrendCount) * 72), 6)}px`,
}"
/>
</div>
</a-tooltip>
<span class="text-[10px] text-gray-400 truncate max-w-[36px]">{{ day.dayName }}</span>
</div>
</div>
</div>
</a-card>
</div>
<!-- 4. Tables / Activity Lists Row -->
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<!-- Recent Messages -->
<a-card title="آخرین پیام‌های تماس">
<template #extra>
<RouterLink :to="{ name: 'messages' }" class="text-xs text-orange-600 hover:underline">
مشاهده همه ←
</RouterLink>
</template>
<a-empty v-if="!data?.recentMessages.length" description="پیامی ثبت نشده است" />
<a-list v-else :data-source="data.recentMessages" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="`${item.name} — ${item.org}`"
:description="item.message || item.phone"
/>
<div class="text-left text-xs text-gray-400">
<a-tag :color="item.status === 'NEW' ? 'orange' : item.status === 'READ' ? 'blue' : 'default'">
{{ MESSAGE_STATUS_LABELS[item.status as keyof typeof MESSAGE_STATUS_LABELS] }}
</a-tag>
<div class="mt-1 font-mono text-[11px]">{{ jalali(item.createdAt) }}</div>
</div>
</a-list-item>
</template>
</a-list>
</a-card>
<!-- Recent Audit Log / Activity -->
<a-card title="آخرین فعالیت‌ها و رویدادهای سیستم">
<a-empty v-if="!data?.recentActivity.length" description="فعالیتی ثبت نشده است" />
<a-timeline v-else class="!mt-2 max-h-[360px] overflow-y-auto pe-2">
<a-timeline-item
v-for="a in data.recentActivity"
:key="a.id"
:color="a.action === 'delete' ? 'red' : a.action === 'create' ? 'green' : 'blue'"
>
<div class="text-sm">
<b>{{ activityLabel(a.action, a.entity) }}</b>
<span v-if="a.summary" class="text-gray-600"> — {{ a.summary }}</span>
</div>
<div class="mt-0.5 text-xs text-gray-400">
{{ a.user ?? "سیستم" }} · {{ jalali(a.createdAt) }}
</div>
</a-timeline-item>
</a-timeline>
</a-card>
</div>
</a-spin>
</template>
+231
View File
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { loginSchema } from "@imen/shared";
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import FormField from "@/components/FormField.vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const { model, validate, handleError } = useZodForm(loginSchema, { phone: "", password: "" });
const loading = ref(false);
async function submit() {
const data = validate();
if (!data) return;
loading.value = true;
try {
await auth.login(data);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
await router.replace(redirect.startsWith("/") ? redirect : "/");
} catch (e) {
handleError(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page relative flex min-h-screen overflow-hidden text-[#edede6]">
<!-- atmosphere -->
<div class="pointer-events-none absolute inset-0 bg-[#0b1224]" />
<div class="login-grid pointer-events-none absolute inset-0 opacity-50" />
<div
class="pointer-events-none absolute -start-24 top-1/4 size-[420px] rounded-full bg-[#ff5a00]/15 blur-[120px]"
/>
<div
class="pointer-events-none absolute -end-16 bottom-0 size-[360px] rounded-full bg-[#ffb800]/10 blur-[100px]"
/>
<div class="login-tape pointer-events-none absolute bottom-0 end-0 z-10 h-48 w-1.5 opacity-70" />
<div class="login-noise pointer-events-none absolute inset-0 z-[5]" />
<div
class="relative z-10 mx-auto grid w-full max-w-[1120px] flex-1 items-center gap-10 px-5 py-12 md:px-8 lg:grid-cols-12 lg:gap-16"
>
<!-- form column -->
<div class="login-fade-3 lg:col-span-6">
<div
class="login-panel w-full max-w-md border border-[#2a3d63]/70 bg-[#1a2744]/80 p-7 backdrop-blur-md md:p-8 lg:me-auto"
>
<div class="mb-7 text-center">
<div class="font-mono text-[10px] tracking-[0.28em] text-[#8e9ab0]">SIGN IN</div>
<h2 class="mt-2 text-xl font-bold text-[#edede6]">ورود به پنل</h2>
<p class="mt-1.5 text-sm text-[#8e9ab0]">شماره موبایل و رمز عبور خود را وارد کنید</p>
</div>
<a-form class="login-form" layout="vertical" @submit.prevent="submit">
<FormField path="phone" label="شماره موبایل">
<a-input
v-model:value="model.phone"
size="large"
class="ltr-input"
autocomplete="tel"
autofocus
inputmode="tel"
placeholder="09121234567"
>
<template #prefix><VuesaxIcon name="mobile" :size="16" class="text-[#8e9ab0]" /></template>
</a-input>
</FormField>
<FormField path="password" label="رمز عبور">
<a-input-password
v-model:value="model.password"
size="large"
class="ltr-input"
autocomplete="current-password"
placeholder="••••••••"
>
<template #prefix><VuesaxIcon name="lock" :size="16" class="text-[#8e9ab0]" /></template>
</a-input-password>
</FormField>
<a-button
type="primary"
html-type="submit"
size="large"
block
:loading="loading"
class="!mt-2 !h-12 !rounded-full !font-bold !shadow-[0_8px_32px_rgba(255,90,0,0.35)]"
>
ورود به پنل
</a-button>
</a-form>
</div>
</div>
<!-- brand column -->
<div class="login-fade-1 lg:col-span-6">
<div
class="inline-flex items-center gap-2.5 rounded-full border border-[#ff5a00]/30 bg-[#ff5a00]/10 px-4 py-2 backdrop-blur"
>
<span class="size-1.5 animate-pulse rounded-full bg-[#ff5a00]" />
<span class="text-xs font-medium text-[#ff5a00]">پنل مدیریت ایمن پیشگامان</span>
</div>
<div class="mt-8 flex items-center gap-4">
<span
class="flex size-14 items-center justify-center rounded-xl bg-white shadow-[0_0_28px_rgba(10,30,92,0.45)]"
>
<img src="/logo.png" alt="ایمن پیشگامان" class="size-9 object-contain" />
</span>
<div class="leading-tight">
<div class="text-2xl font-black tracking-tight md:text-3xl">ایمن پیشگامان</div>
<div class="mt-1 font-mono text-[10px] tracking-[0.32em] text-[#8e9ab0]">
IMEN PISHGAMAN CO.
</div>
</div>
</div>
<h1 class="mt-8 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]">
ورود امن
<span
class="mt-2 inline-block -rotate-1 rounded-lg bg-[#ffb800] px-3 py-1 text-[#0b1224] shadow-[0_10px_40px_rgba(255,184,0,0.22)]"
>
به سامانه
</span>
</h1>
<p class="mt-6 max-w-md text-sm leading-8 text-[#edede6]/70 md:text-base md:leading-9">
مدیریت محصولات، محتوا و پیام‌های سایت — فقط برای کاربران مجاز.
</p>
</div>
</div>
</div>
</template>
<style scoped>
.login-grid {
background-image:
linear-gradient(to left, rgba(237, 237, 230, 0.045) 1px, transparent 1px),
linear-gradient(to bottom, rgba(237, 237, 230, 0.045) 1px, transparent 1px);
background-size: 72px 72px;
}
.login-tape {
background-image: repeating-linear-gradient(45deg, #ffb800 0 10px, #0b1224 10px 20px);
}
.login-noise {
opacity: 0.045;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.login-fade-1,
.login-fade-3 {
animation: login-fade-up 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.login-fade-1 {
animation-delay: 0.12s;
}
.login-fade-3 {
animation-delay: 0.28s;
}
@keyframes login-fade-up {
from {
opacity: 0;
transform: translateY(36px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-page {
color-scheme: dark;
}
.login-panel :deep(.ant-form-item-label > label) {
color: #edede6 !important;
}
.login-panel :deep(.ant-input-affix-wrapper),
.login-panel :deep(.ant-input) {
background: #152038 !important;
border-color: #2a3d63 !important;
color: #edede6 !important;
}
.login-panel :deep(.ant-input-affix-wrapper input.ant-input),
.login-panel :deep(.ant-input-affix-wrapper input) {
background: transparent !important;
color: #edede6 !important;
}
.login-panel :deep(.ant-input-affix-wrapper:hover),
.login-panel :deep(.ant-input-affix-wrapper-focused),
.login-panel :deep(.ant-input-affix-wrapper:focus-within) {
border-color: #ff5a00 !important;
box-shadow: 0 0 0 2px rgba(255, 90, 0, 0.15) !important;
}
.login-panel :deep(.ant-input::placeholder) {
color: #8e9ab0 !important;
}
.login-panel :deep(.ant-input-password-icon),
.login-panel :deep(.ant-input-suffix) {
color: #8e9ab0 !important;
}
/* Fix browser autofill white background and black text */
.login-panel :deep(input:-webkit-autofill),
.login-panel :deep(input:-webkit-autofill:hover),
.login-panel :deep(input:-webkit-autofill:focus),
.login-panel :deep(input:-webkit-autofill:active) {
-webkit-box-shadow: 0 0 0 1000px #152038 inset !important;
box-shadow: 0 0 0 1000px #152038 inset !important;
-webkit-text-fill-color: #edede6 !important;
caret-color: #edede6 !important;
transition: background-color 99999s ease-in-out 0s;
}
.login-panel :deep(.ant-form-item-explain-error) {
color: #ff7a2a;
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, onUnmounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { faNumber, fileSize, jalali } from "@/utils/format";
const auth = useAuthStore();
const items = ref<MediaDto[]>([]);
const total = ref(0);
const page = ref(1);
const q = ref("");
const loading = ref(false);
const uploading = ref(0);
const selected = ref<MediaDto | null>(null);
const altDraft = ref("");
const PAGE_SIZE = 36;
const isMobile = ref(false);
function updateMobile() {
if (typeof window !== "undefined") {
isMobile.value = window.innerWidth < 1024;
}
}
async function load() {
loading.value = true;
try {
const res = await api<Paginated<MediaDto>>("/admin/media", {
query: { page: page.value, pageSize: PAGE_SIZE, q: q.value || undefined },
});
items.value = res.items;
total.value = res.total;
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
async function upload(file: File) {
uploading.value++;
try {
const body = new FormData();
body.append("file", file);
await api<MediaDto>("/admin/media", { method: "POST", body });
void message.success(`«${file.name}» آپلود شد`);
page.value = 1;
await load();
} catch (e) {
void message.error(`${file.name}: ${errorMessage(e)}`);
} finally {
uploading.value--;
}
return false;
}
function open(m: MediaDto) {
selected.value = m;
altDraft.value = m.alt;
}
async function saveAlt() {
if (!selected.value) return;
try {
const m = await api<MediaDto>(`/admin/media/${selected.value.id}`, { method: "PATCH", body: { alt: altDraft.value } });
Object.assign(selected.value, m);
void message.success("ذخیره شد");
} catch (e) {
void message.error(errorMessage(e));
}
}
async function remove() {
if (!selected.value) return;
try {
await api(`/admin/media/${selected.value.id}`, { method: "DELETE" });
void message.success("تصویر حذف شد");
selected.value = null;
await load();
} catch (e) {
void message.error(errorMessage(e));
}
}
async function copyUrl(url: string) {
await navigator.clipboard.writeText(new URL(url, location.origin).toString());
void message.success("آدرس کپی شد");
}
onMounted(() => {
updateMobile();
window.addEventListener("resize", updateMobile);
void load();
});
onUnmounted(() => {
if (typeof window !== "undefined") {
window.removeEventListener("resize", updateMobile);
}
});
</script>
<template>
<PageHeader title="کتابخانه رسانه" :subtitle="`${faNumber(total)} تصویر`">
<a-upload accept="image/jpeg,image/png,image/webp,image/avif,image/gif" multiple :show-upload-list="false" :before-upload="upload">
<a-button type="primary" :loading="uploading > 0">
<template #icon><VuesaxIcon name="document-upload" :size="16" /></template>
آپلود تصویر
</a-button>
</a-upload>
</PageHeader>
<a-card>
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو در نام فایل یا متن جایگزین"
class="mb-4 w-full md:max-w-md"
@search="() => { page = 1; load(); }"
/>
<a-spin :spinning="loading">
<a-empty v-if="!items.length && !loading" description="تصویری پیدا نشد" />
<div class="grid grid-cols-3 gap-2.5 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-9">
<button
v-for="m in items"
:key="m.id"
type="button"
class="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition active:scale-95 hover:border-[#ff5a00] focus:outline-none"
:title="m.originalName"
@click="open(m)"
>
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain p-1" loading="lazy" />
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-1 text-[9px] text-white opacity-0 transition group-hover:opacity-100 max-lg:hidden truncate">
{{ m.originalName }}
</div>
</button>
</div>
</a-spin>
<div class="mt-4 flex justify-center">
<a-pagination
v-model:current="page"
:total="total"
:page-size="PAGE_SIZE"
:show-size-changer="false"
:simple="isMobile"
@change="load"
/>
</div>
</a-card>
<!-- Mobile Floating Upload Button -->
<div class="lg:hidden fixed bottom-20 left-4 z-30">
<a-upload accept="image/jpeg,image/png,image/webp,image/avif,image/gif" multiple :show-upload-list="false" :before-upload="upload">
<button
type="button"
class="flex size-13 items-center justify-center rounded-full bg-[#ff5a00] text-white shadow-lg shadow-orange-500/30 transition-transform active:scale-95"
title="آپلود تصویر"
:disabled="uploading > 0"
>
<VuesaxIcon v-if="uploading === 0" name="document-upload" :size="24" />
<span v-else class="text-xs font-bold">{{ uploading }}</span>
</button>
</a-upload>
</div>
<!-- Image Detail Drawer (Side on Desktop, Bottom Sheet on Mobile) -->
<a-drawer
:open="!!selected"
title="جزئیات تصویر"
:width="isMobile ? '100%' : 380"
:height="isMobile ? '82vh' : undefined"
:placement="isMobile ? 'bottom' : 'left'"
:body-style="{ paddingBottom: isMobile ? '80px' : '24px' }"
@close="selected = null"
>
<template v-if="selected">
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 flex items-center justify-center p-2">
<img :src="selected.url" :alt="selected.alt" class="max-h-60 w-full object-contain" />
</div>
<a-descriptions :column="isMobile ? 2 : 1" size="small" class="mt-4">
<a-descriptions-item label="نام فایل" :span="isMobile ? 2 : 1">
<span dir="ltr" class="truncate block max-w-[240px] text-xs font-mono text-gray-700">{{ selected.originalName }}</span>
</a-descriptions-item>
<a-descriptions-item label="ابعاد">
<span dir="ltr" class="text-xs font-mono">{{ selected.width }}×{{ selected.height }}</span>
</a-descriptions-item>
<a-descriptions-item label="حجم">{{ fileSize(selected.size) }}</a-descriptions-item>
<a-descriptions-item label="تاریخ" :span="isMobile ? 2 : 1">{{ jalali(selected.createdAt) }}</a-descriptions-item>
</a-descriptions>
<a-form layout="vertical" class="mt-4">
<a-form-item label="متن جایگزین (alt)" class="!mb-3">
<a-input v-model:value="altDraft" placeholder="توضیح کوتاه برای سئو و دسترس‌پذیری" />
</a-form-item>
<!-- Actions: Desktop row vs Mobile full touch grid -->
<div class="max-lg:grid max-lg:grid-cols-2 max-lg:gap-2 max-lg:mt-4 max-lg:hidden">
<a-space wrap>
<a-button type="primary" @click="saveAlt">ذخیره</a-button>
<a-button @click="copyUrl(selected.url)"><template #icon><VuesaxIcon name="copy" :size="16" /></template>کپی آدرس</a-button>
<a-popconfirm
v-if="auth.isAdmin"
title="این تصویر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove"
>
<a-button danger><template #icon><VuesaxIcon name="trash" :size="16" /></template>حذف</a-button>
</a-popconfirm>
</a-space>
</div>
<div class="lg:hidden grid grid-cols-2 gap-2 mt-4">
<a-button type="primary" class="h-10 !font-bold" @click="saveAlt">ذخیره متن</a-button>
<a-button class="h-10 !flex !items-center !justify-center !gap-1" @click="copyUrl(selected.url)">
<template #icon><VuesaxIcon name="copy" :size="16" /></template>
<span>کپی آدرس</span>
</a-button>
<a-popconfirm
v-if="auth.isAdmin"
title="این تصویر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove"
>
<a-button danger class="col-span-2 h-10 !flex !items-center !justify-center !gap-1">
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
<span>حذف تصویر</span>
</a-button>
</a-popconfirm>
</div>
</a-form>
</template>
</a-drawer>
</template>
+292
View File
@@ -0,0 +1,292 @@
<script setup lang="ts">
import {
type ContactMessageDto,
MESSAGE_STATUS_LABELS,
MESSAGE_STATUSES,
type MessageStatus,
type Paginated,
} from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const route = useRoute();
/** "ALL" or one status */
const status = ref<MessageStatus | "ALL">((route.query.status as MessageStatus) || "ALL");
const STATUS_OPTIONS = MESSAGE_STATUSES.map((s) => ({ value: s, label: MESSAGE_STATUS_LABELS[s] }));
const FILTER_OPTIONS = [{ value: "ALL", label: "همه" }, ...STATUS_OPTIONS];
const page = ref(1);
const rows = ref<ContactMessageDto[]>([]);
const total = ref(0);
const loading = ref(false);
const expandedIds = ref<number[]>([]);
const PAGE_SIZE = 20;
const totalPages = computed(() => Math.ceil(total.value / PAGE_SIZE) || 1);
async function load() {
loading.value = true;
try {
const res = await api<Paginated<ContactMessageDto>>("/admin/messages", {
query: { status: status.value === "ALL" ? undefined : status.value, page: page.value, pageSize: PAGE_SIZE },
});
rows.value = res.items;
total.value = res.total;
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
async function setStatus(row: ContactMessageDto, s: MessageStatus) {
try {
await api(`/admin/messages/${row.id}`, { method: "PATCH", body: { status: s } });
row.status = s;
void message.success(`وضعیت پیام به «${MESSAGE_STATUS_LABELS[s]}» تغییر کرد`);
} catch (e) {
void message.error(errorMessage(e));
}
}
function toggleExpand(row: ContactMessageDto) {
const idx = expandedIds.value.indexOf(row.id);
if (idx > -1) {
expandedIds.value.splice(idx, 1);
} else {
expandedIds.value.push(row.id);
if (row.status === "NEW") {
void setStatus(row, "READ");
}
}
}
/** opening a new message marks it as read */
function onExpand(expanded: boolean, row: ContactMessageDto) {
if (expanded && row.status === "NEW") void setStatus(row, "READ");
}
async function remove(row: ContactMessageDto) {
try {
await api(`/admin/messages/${row.id}`, { method: "DELETE" });
void message.success("پیام حذف شد");
await load();
} catch (e) {
void message.error(errorMessage(e));
}
}
function onMobilePageChange(p: number) {
if (p < 1 || p > totalPages.value) return;
page.value = p;
void load();
}
const STATUS_COLORS: Record<MessageStatus, string> = { NEW: "orange", READ: "blue", ARCHIVED: "default" };
const columns = [
{ title: "نام", dataIndex: "name" },
{ title: "سازمان", dataIndex: "org" },
{ title: "تلفن", key: "phone", width: 150 },
{ title: "تاریخ", key: "date", width: 170 },
{ title: "وضعیت", key: "status", width: 140 },
{ title: "", key: "actions", width: 60 },
];
watch(status, () => {
page.value = 1;
void load();
});
onMounted(load);
</script>
<template>
<PageHeader title="پیام‌های تماس" :subtitle="`${faNumber(total)} پیام — استعلام‌ها و درخواست‌های ثبت‌شده از سایت`">
<div class="!hidden lg:!block">
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
</div>
</PageHeader>
<!-- Mobile Segmented Filter Row (Mobile Only) -->
<div class="mobile-filter-bar sticky top-[52px] z-20 bg-white/95 backdrop-blur-md -mx-3 px-3 py-2 border-b border-slate-100 flex items-center gap-1.5 overflow-x-auto no-scrollbar mb-3 lg:!hidden">
<button
v-for="opt in FILTER_OPTIONS"
:key="opt.value"
type="button"
class="px-3 py-1.5 rounded-full text-xs font-bold transition shrink-0 select-none cursor-pointer"
:class="status === opt.value ? 'bg-orange-600 text-white shadow-xs' : 'bg-slate-100 text-slate-600 active:bg-slate-200'"
@click="status = opt.value as any"
>
{{ opt.label }}
</button>
</div>
<!-- Desktop Table View (lg and up) -->
<a-card class="!hidden lg:!block" :body-style="{ padding: 0 }">
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
row-key="id"
:scroll="{ x: 760 }"
:pagination="{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }"
@expand="onExpand"
>
<template #expandedRowRender="{ record }">
<p class="whitespace-pre-line leading-7">{{ record.message || "— بدون توضیح —" }}</p>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'phone'">
<a :href="`tel:${record.phone}`" dir="ltr" class="hover:text-orange-600 font-mono">{{ record.phone }}</a>
</template>
<template v-else-if="column.key === 'date'">{{ jalali(record.createdAt) }}</template>
<template v-else-if="column.key === 'status'">
<a-select
:value="record.status"
size="small"
class="w-28"
:options="STATUS_OPTIONS"
@change="(v: unknown) => setStatus(record as ContactMessageDto, v as MessageStatus)"
>
<template #suffixIcon><a-badge :color="STATUS_COLORS[record.status as MessageStatus]" /></template>
</a-select>
</template>
<template v-else-if="column.key === 'actions'">
<a-popconfirm
v-if="auth.isAdmin"
title="این پیام حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as ContactMessageDto)"
>
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
<!-- Mobile Message Cards View (Mobile Only) -->
<div class="mobile-messages-view block lg:!hidden pb-20">
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="پیامی با این وضعیت موجود نیست" class="py-12" />
<div v-else class="space-y-3">
<div
v-for="record in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3.5 shadow-2xs transition"
:class="{ 'border-orange-300 ring-2 ring-orange-100': record.status === 'NEW' }"
>
<!-- Top Row: Sender Info + Status Pill -->
<div class="flex items-start justify-between gap-2">
<div>
<div class="flex items-center gap-1.5">
<span class="font-bold text-[14px] text-slate-800">{{ record.name }}</span>
<span v-if="record.status === 'NEW'" class="size-2 rounded-full bg-orange-500 animate-pulse" />
</div>
<div v-if="record.org" class="text-xs text-slate-500 font-medium mt-0.5">
{{ record.org }}
</div>
</div>
<a-tag :color="STATUS_COLORS[record.status as MessageStatus]" class="!m-0 !text-[11px] !font-bold">
{{ MESSAGE_STATUS_LABELS[record.status as keyof typeof MESSAGE_STATUS_LABELS] }}
</a-tag>
</div>
<!-- Middle Row: Date & Call Button -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-2.5">
<span class="text-[11px] text-slate-400 font-mono">
{{ jalali(record.createdAt) }}
</span>
<!-- Direct Call Button -->
<a
:href="'tel:' + record.phone"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200/70 font-bold text-xs active:bg-emerald-100 active:scale-95 transition"
title="تماس مستقیم با مشتری"
>
<Phone :size="13" class="text-emerald-600 shrink-0" />
<span dir="ltr" class="font-mono">{{ record.phone }}</span>
</a>
</div>
<!-- Message Body Preview & Full Content -->
<div class="mt-3 cursor-pointer" @click="toggleExpand(record)">
<div
v-if="!expandedIds.includes(record.id)"
class="text-xs text-slate-600 line-clamp-2 leading-relaxed bg-slate-50/70 p-2.5 rounded-lg border border-slate-100"
>
{{ record.message || "— بدون توضیح —" }}
<div class="text-[10px] text-orange-600 font-bold mt-1">مشاهده متن کامل پیام ←</div>
</div>
<div
v-else
class="text-xs text-slate-700 whitespace-pre-line leading-relaxed bg-orange-50/30 p-3 rounded-lg border border-orange-100"
>
{{ record.message || "— بدون توضیح —" }}
<div class="text-[10px] text-slate-400 font-medium mt-2">بستن متن پیام ↑</div>
</div>
</div>
<!-- Footer Actions: Status Changer + Delete -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-3">
<div class="flex items-center gap-2">
<span class="text-[11px] text-slate-400">وضعیت:</span>
<a-select
:value="record.status"
size="small"
class="w-28 !text-xs"
:options="STATUS_OPTIONS"
@change="(v: unknown) => setStatus(record, v as MessageStatus)"
/>
</div>
<a-popconfirm
v-if="auth.isAdmin"
title="این پیام حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 active:bg-rose-200 transition"
title="حذف پیام"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
<!-- Mobile Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between gap-3 pt-4 px-1">
<a-button
:disabled="page <= 1"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(page - 1)"
>
صفحه قبلی
</a-button>
<span class="text-xs text-slate-500 font-mono">
{{ page.toLocaleString("fa-IR") }} / {{ totalPages.toLocaleString("fa-IR") }}
</span>
<a-button
:disabled="page >= totalPages"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(page + 1)"
>
صفحه بعدی
</a-button>
</div>
</a-spin>
</div>
</template>
+442
View File
@@ -0,0 +1,442 @@
<script setup lang="ts">
import { type MediaDto, type ProductAttribute, productInputSchema } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { z } from "zod";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { AdminCategory, AdminCategoryDetail, AdminProductDetail } from "@/api/types";
import FormField from "@/components/FormField.vue";
import ImageField from "@/components/ImageField.vue";
import LinesInput from "@/components/LinesInput.vue";
import ListEditor from "@/components/ListEditor.vue";
import MediaPicker from "@/components/MediaPicker.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { SITE_URL } from "@/utils/format";
import { toSlug } from "@/utils/slug";
type Form = z.output<typeof productInputSchema>;
const route = useRoute();
const router = useRouter();
const id = computed(() => (route.params.id ? Number(route.params.id) : null));
const empty = (): Form => ({
categoryId: Number(route.query.categoryId) || (undefined as unknown as number),
groupId: null,
code: "",
slug: "",
name: "",
nameEn: "",
desc: "",
specs: [],
variants: [],
details: "",
brand: "",
origin: "",
sizes: [],
standards: [],
features: [],
applications: [],
attributes: [],
packaging: "",
minOrder: "",
seoTitle: "",
seoDescription: "",
imageId: null,
galleryIds: [],
isPublished: true,
});
const { model, validate, handleError, reset } = useZodForm(productInputSchema, empty());
const image = ref<MediaDto | null>(null);
const gallery = ref<MediaDto[]>([]);
const galleryPicker = ref(false);
const categories = ref<AdminCategory[]>([]);
const groups = ref<AdminCategoryDetail["groups"]>([]);
const loading = ref(false);
const saving = ref(false);
const mobileTab = ref<"general" | "specs" | "desc" | "media" | "seo">("general");
const MOBILE_TABS = [
{ key: "general", label: "اطلاعات اصلی" },
{ key: "specs", label: "مشخصات فنی" },
{ key: "desc", label: "توضیحات و کاربرد" },
{ key: "media", label: "تصاویر و سفارش" },
{ key: "seo", label: "سئو و انتشار" },
] as const;
const category = computed(() => categories.value.find((c) => c.id === model.value.categoryId));
/** a-select clears to undefined, the API expects null */
const groupId = computed({
get: () => model.value.groupId ?? undefined,
set: (v: number | undefined) => (model.value.groupId = v ?? null),
});
const newAttribute = (): ProductAttribute => ({ name: "", value: "" });
async function loadGroups(clear: boolean) {
if (clear) model.value.groupId = null;
groups.value = model.value.categoryId
? (await api<AdminCategoryDetail>(`/admin/categories/${model.value.categoryId}`)).groups
: [];
}
async function load() {
loading.value = true;
try {
categories.value = await api<AdminCategory[]>("/admin/categories");
if (id.value) {
const p = await api<AdminProductDetail>(`/admin/products/${id.value}`);
reset({
categoryId: p.categoryId,
groupId: p.groupId,
code: p.code,
slug: p.slug,
name: p.name,
nameEn: p.nameEn,
desc: p.desc,
specs: p.specs,
variants: p.variants,
details: p.details,
brand: p.brand,
origin: p.origin,
sizes: p.sizes,
standards: p.standards,
features: p.features,
applications: p.applications,
attributes: p.attributes ?? [],
packaging: p.packaging,
minOrder: p.minOrder,
seoTitle: p.seoTitle,
seoDescription: p.seoDescription,
imageId: p.imageId,
galleryIds: p.gallery.map((g) => g.id),
isPublished: p.isPublished,
});
image.value = p.image;
gallery.value = p.gallery;
} else {
reset(empty());
image.value = null;
gallery.value = [];
}
await loadGroups(false);
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
function pickImage(m: MediaDto) {
image.value = m;
model.value.imageId = m.id;
}
function addToGallery(m: MediaDto) {
if (gallery.value.some((g) => g.id === m.id)) return;
gallery.value = [...gallery.value, m];
}
function removeFromGallery(mediaId: number) {
gallery.value = gallery.value.filter((g) => g.id !== mediaId);
}
watch(gallery, (g) => (model.value.galleryIds = g.map((m) => m.id)));
async function save() {
const data = validate();
if (!data) return;
saving.value = true;
try {
const saved = await api<AdminProductDetail>(id.value ? `/admin/products/${id.value}` : "/admin/products", {
method: id.value ? "PUT" : "POST",
body: data,
});
void message.success("محصول ذخیره شد");
if (!id.value) await router.replace({ name: "product-edit", params: { id: saved.id } });
else await load();
} catch (e) {
handleError(e);
} finally {
saving.value = false;
}
}
// /products/new and /products/:id share this component
watch(id, load);
onMounted(load);
</script>
<template>
<PageHeader :title="id ? `ویرایش محصول: ${model.name}` : 'محصول جدید'" back="/products">
<div class="!hidden lg:!flex items-center gap-2">
<a
v-if="id && category?.isPublished && model.isPublished"
:href="`${SITE_URL}/products/${model.slug}`"
target="_blank"
rel="noopener"
>
<a-button>مشاهده در سایت</a-button>
</a>
<a-button type="primary" :loading="saving" @click="save">
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</div>
</PageHeader>
<a-spin :spinning="loading">
<a-form layout="vertical" class="pb-24 lg:pb-6" @submit.prevent="save">
<!-- Mobile Segmented Navigation Tabs -->
<div
class="mobile-edit-tabs lg:!hidden flex items-center gap-1.5 overflow-x-auto pb-2.5 mb-3 sticky top-[52px] z-20 bg-white/95 backdrop-blur-md -mx-3 px-3 pt-1 border-b border-slate-100 no-scrollbar"
>
<button
v-for="t in MOBILE_TABS"
:key="t.key"
type="button"
class="px-3 py-1.5 rounded-full text-xs font-bold transition shrink-0 select-none cursor-pointer"
:class="
mobileTab === t.key
? 'bg-orange-600 text-white shadow-xs'
: 'bg-slate-100 text-slate-600 active:bg-slate-200'
"
@click="mobileTab = t.key"
>
{{ t.label }}
</button>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<!-- Card 1: Main Info -->
<a-card title="اطلاعات محصول" :class="mobileTab !== 'general' && 'max-lg:hidden'">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام محصول" required>
<a-input v-model:value="model.name" />
</FormField>
<FormField path="nameEn" label="نام انگلیسی">
<a-input v-model:value="model.nameEn" class="ltr-input" />
</FormField>
<FormField path="code" label="کد محصول" required hint="یکتا، مثلاً IP-G20">
<a-input v-model:value="model.code" class="ltr-input" />
</FormField>
<FormField path="slug" label="اسلاگ (آدرس صفحه)" required :hint="`آدرس: /products/${model.slug || '...'}`">
<a-input v-model:value="model.slug" class="ltr-input">
<template #suffix>
<a class="text-xs" @click="model.slug = toSlug(model.code || model.nameEn || '')">ساخت از کد</a>
</template>
</a-input>
</FormField>
<FormField path="desc" label="توضیح کوتاه" hint="زیر عنوان صفحه محصول و روی کارت محصول نمایش داده می‌شود" class="md:col-span-2">
<a-textarea v-model:value="model.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
<FormField path="specs" label="برچسب‌های کوتاه" hint="ویژگی‌های یک‌کلمه‌ای روی کارت؛ هر مورد را بنویسید و Enter بزنید" class="md:col-span-2">
<a-select v-model:value="model.specs" mode="tags" :open="false" placeholder="مثلاً: ضد برش" />
</FormField>
</div>
</a-card>
<!-- Card 2: Details Description -->
<a-card title="توضیحات کامل (صفحه محصول)" :class="mobileTab !== 'desc' && 'max-lg:hidden'">
<FormField path="details" hint="برای شروع پاراگراف جدید یک خط خالی بگذارید. اگر خالی بماند، توضیح دسته نمایش داده می‌شود.">
<a-textarea
v-model:value="model.details"
:auto-size="{ minRows: 6, maxRows: 20 }"
placeholder="معرفی محصول، جنس و ساختار، مزایا، نکات نگهداری و ..."
/>
</FormField>
</a-card>
<!-- Card 3: Features & Applications -->
<a-card title="ویژگی‌ها و کاربردها" :class="mobileTab !== 'desc' && 'max-lg:hidden'">
<FormField path="features" label="ویژگی‌های کلیدی" hint="هر ویژگی در یک خط">
<LinesInput v-model="model.features" :rows="4" placeholder="مثلاً: زیره ضد لغزش و مقاوم در برابر روغن" />
</FormField>
<FormField path="applications" label="کاربردها / محیط‌های مناسب" hint="هر مورد را بنویسید و Enter بزنید">
<a-select v-model:value="model.applications" mode="tags" :open="false" placeholder="مثلاً: پالایشگاه" />
</FormField>
</a-card>
<!-- Card 4: Technical Attributes & Specifications -->
<a-card title="مشخصات فنی" :body-style="{ background: '#fafafa' }" :class="mobileTab !== 'specs' && 'max-lg:hidden'">
<p class="mb-4 text-sm text-gray-500">
ردیف‌های جدول مشخصات صفحه محصول (مثلاً «جنس رویه» ← «چرم طبیعی»). کد، دسته، برند و موارد زیر به‌صورت خودکار به جدول اضافه می‌شوند.
</p>
<ListEditor
v-model="model.attributes"
:create="newAttribute"
:max="40"
add-label="افزودن ردیف مشخصات"
:item-title="(a) => a.name"
>
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-5">
<FormField :path="`attributes.${index}.name`" label="عنوان" required class="md:col-span-2">
<a-input v-model:value="item.name" placeholder="مثلاً: جنس رویه" />
</FormField>
<FormField :path="`attributes.${index}.value`" label="مقدار" required class="md:col-span-3">
<a-input v-model:value="item.value" placeholder="مثلاً: چرم طبیعی گاوی" />
</FormField>
</div>
</template>
</ListEditor>
<div class="mt-6 grid gap-x-4 md:grid-cols-2">
<FormField path="standards" label="استانداردها و گواهی‌ها" hint="مثلاً EN 388 — هر مورد + Enter">
<a-select v-model:value="model.standards" mode="tags" :open="false" class="ltr-input" />
</FormField>
<FormField path="sizes" label="سایزها" hint="مثلاً 40، 41، XL — هر مورد + Enter">
<a-select v-model:value="model.sizes" mode="tags" :open="false" />
</FormField>
<FormField path="variants" label="مدل‌ها / رنگ‌ها" hint="هر مورد + Enter" class="md:col-span-2">
<a-select v-model:value="model.variants" mode="tags" :open="false" placeholder="مثلاً: مشکی/زرد" />
</FormField>
</div>
</a-card>
</div>
<div class="space-y-6">
<!-- Card: Publishing (Desktop & Mobile SEO tab) -->
<a-card title="انتشار" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "پیش‌نویس (مخفی)" }}</span>
</FormField>
</a-card>
<!-- Card: Category & Group (General tab on mobile) -->
<a-card title="دسته‌بندی" :class="mobileTab !== 'general' && 'max-lg:hidden'">
<FormField path="categoryId" label="دسته" required>
<a-select
v-model:value="model.categoryId"
placeholder="انتخاب دسته"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
@change="loadGroups(true)"
/>
</FormField>
<FormField path="groupId" label="زیرگروه">
<a-select
v-model:value="groupId"
allow-clear
:disabled="!groups.length"
:placeholder="groups.length ? 'بدون زیرگروه' : 'این دسته زیرگروه ندارد'"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
</FormField>
</a-card>
<!-- Card: Product Images (Cover + Gallery) -->
<a-card title="تصاویر محصول" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<!-- Cover / Main Image -->
<div>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-700">تصویر اصلی (کاور)</span>
<span class="text-[11px] text-slate-400">نمایش در لیست‌ها و کارت‌ها</span>
</div>
<FormField path="imageId" hint="در صورت عدم انتخاب، کاور دسته‌بندی استفاده می‌شود">
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
</FormField>
</div>
<div class="my-5 border-t border-slate-100" />
<!-- Gallery Images -->
<div>
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs font-bold text-slate-700">گالری تصاویر بیشتر</span>
<span v-if="gallery.length" class="text-[11px] text-orange-600 font-mono font-bold">{{ gallery.length }} تصویر</span>
</div>
<p class="mb-3 text-xs text-slate-400">تصاویری که در اسلایدر جزئیات محصول نمایش داده می‌شوند.</p>
<div class="flex flex-wrap gap-2.5">
<div v-for="m in gallery" :key="m.id" class="relative size-20 overflow-hidden rounded-lg border border-slate-200 bg-slate-50">
<img :src="m.url" alt="" class="size-full object-contain p-1" />
<button
type="button"
class="absolute end-1 top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-white shadow-xs transition hover:bg-red-600 cursor-pointer"
title="حذف از گالری"
@click="removeFromGallery(m.id)"
>
<VuesaxIcon name="close-circle" :size="12" />
</button>
</div>
<button
type="button"
class="flex size-20 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-slate-300 text-slate-400 hover:border-[#ff5a00] hover:text-[#ff5a00] hover:bg-orange-50/30 transition cursor-pointer"
@click="galleryPicker = true"
>
<VuesaxIcon name="add" :size="18" />
<span class="text-[10px] font-medium">افزودن</span>
</button>
</div>
</div>
<MediaPicker v-model:open="galleryPicker" @select="addToGallery" />
</a-card>
<!-- Card 9: Brand and Ordering (Media tab on mobile) -->
<a-card title="برند و سفارش" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="brand" label="برند">
<a-input v-model:value="model.brand" />
</FormField>
<FormField path="origin" label="کشور سازنده">
<a-input v-model:value="model.origin" placeholder="مثلاً: ایران" />
</FormField>
<FormField path="packaging" label="بسته‌بندی">
<a-input v-model:value="model.packaging" placeholder="مثلاً: کارتن ۱۲ جفتی" />
</FormField>
<FormField path="minOrder" label="حداقل سفارش">
<a-input v-model:value="model.minOrder" placeholder="مثلاً: ۱ کارتن" />
</FormField>
</a-card>
<!-- Card 10: SEO (SEO tab on mobile) -->
<a-card title="سئو" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام محصول">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField>
<FormField path="seoDescription" label="توضیحات متا" hint="خالی = توضیح کوتاه">
<a-textarea v-model:value="model.seoDescription" :maxlength="300" show-count :auto-size="{ minRows: 3 }" />
</FormField>
</a-card>
</div>
</div>
<!-- Mobile Sticky Bottom Action Bar -->
<div
class="fixed bottom-0 start-0 end-0 z-30 bg-white/95 backdrop-blur-md border-t border-slate-200/80 p-3 shadow-lg flex items-center justify-between gap-3 lg:!hidden"
>
<div class="flex items-center gap-2">
<a-switch v-model:checked="model.isPublished" size="small" />
<span class="text-xs" :class="model.isPublished ? 'text-emerald-600 font-bold' : 'text-slate-400'">
{{ model.isPublished ? "منتشرشده" : "پیش‌نویس" }}
</span>
</div>
<div class="flex items-center gap-2">
<a
v-if="id && category?.isPublished && model.isPublished"
:href="`${SITE_URL}/products/${model.slug}`"
target="_blank"
rel="noopener"
class="size-10 rounded-xl bg-slate-100 flex items-center justify-center text-slate-600 hover:text-orange-600 active:bg-slate-200"
title="مشاهده در سایت"
>
<VuesaxIcon name="export" :size="18" />
</a>
<a-button
type="primary"
class="!h-10 !px-5 !rounded-xl !font-bold !flex !items-center !gap-1.5 !shadow-sm"
:loading="saving"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره محصول
</a-button>
</div>
</div>
</a-form>
</a-spin>
</template>
+842
View File
@@ -0,0 +1,842 @@
<script setup lang="ts">
import type { Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api, errorMessage } from "@/api/http";
import type { AdminCategory, AdminCategoryDetail, AdminProduct } from "@/api/types";
import AdminPage from "@/components/AdminPage.vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useAuthStore } from "@/stores/auth";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const tableShell = ref<HTMLElement | null>(null);
const scrollY = ref(360);
let tableResizeObserver: ResizeObserver | null = null;
function measureTableScroll() {
const el = tableShell.value;
if (!el) return;
const pagination = el.querySelector(".ant-table-pagination") as HTMLElement | null;
const header = el.querySelector(".ant-table-thead") as HTMLElement | null;
const paginationH = pagination?.offsetHeight ?? (reorderable.value ? 0 : 48);
const headerH = header?.offsetHeight ?? 55;
const gap = 16;
scrollY.value = Math.max(180, el.clientHeight - paginationH - headerH - gap);
}
const num = (v: unknown) => (v ? Number(v) : undefined);
const PAGE_SIZE_OPTIONS = ["10", "20", "50", "100"];
const DEFAULT_PAGE_SIZE = 20;
const filters = reactive({
categoryId: num(route.query.categoryId),
groupId: num(route.query.groupId),
status: (route.query.status as "published" | "draft" | undefined) || undefined,
q: typeof route.query.q === "string" ? route.query.q : "",
page: num(route.query.page) ?? 1,
pageSize: num(route.query.pageSize) ?? DEFAULT_PAGE_SIZE,
});
const rows = ref<AdminProduct[]>([]);
const total = ref(0);
const loading = ref(false);
const categories = ref<AdminCategory[]>([]);
const groups = ref<AdminCategoryDetail["groups"]>([]);
const filtersOpen = ref(false);
const mobileFilterDrawer = ref(false);
const activeFilterCount = computed(
() => [filters.categoryId, filters.groupId, filters.status].filter(Boolean).length,
);
const activeCategoryName = computed(
() => categories.value.find((c) => c.id === filters.categoryId)?.name,
);
const activeGroupName = computed(
() => groups.value.find((g) => g.id === filters.groupId)?.name,
);
const totalPages = computed(() => Math.ceil(total.value / filters.pageSize) || 1);
function clearAllFilters() {
filters.categoryId = undefined;
filters.groupId = undefined;
filters.status = undefined;
filters.q = "";
filters.page = 1;
void load();
}
function clearCategoryFilter() {
filters.categoryId = undefined;
filters.groupId = undefined;
filters.page = 1;
void load();
}
function clearGroupFilter() {
filters.groupId = undefined;
filters.page = 1;
void load();
}
function clearStatusFilter() {
filters.status = undefined;
filters.page = 1;
void load();
}
function clearSearch() {
filters.q = "";
filters.page = 1;
void load();
}
function onMobilePageChange(p: number) {
if (p < 1 || p > totalPages.value) return;
filters.page = p;
void load();
}
/** products without their own photo show the category cover (same fallback as the website) */
const thumbOf = (row: AdminProduct) =>
row.image
? { url: row.image.url, fallback: false }
: {
url: categories.value.find((c) => c.id === row.categoryId)?.coverImage?.url ?? null,
fallback: true,
};
/** with a single category selected (and no other filter) the whole list is shown and can be reordered */
const reorderable = computed(
() => !!filters.categoryId && !filters.groupId && !filters.status && !filters.q,
);
const requestPageSize = computed(() => (reorderable.value ? 200 : filters.pageSize));
const tablePagination = computed(() => {
if (reorderable.value) return false;
return {
current: filters.page,
pageSize: filters.pageSize,
total: total.value,
showSizeChanger: true,
pageSizeOptions: PAGE_SIZE_OPTIONS,
showQuickJumper: true,
showTotal: (t: number) => `${t.toLocaleString("fa-IR")} محصول`,
onChange: (page: number, size: number) => {
filters.page = page;
if (size !== filters.pageSize) {
filters.pageSize = size;
filters.page = 1;
}
void load();
},
};
});
async function load() {
loading.value = true;
void router.replace({
query: Object.fromEntries(
Object.entries({
...filters,
page: filters.page === 1 ? undefined : filters.page,
pageSize: filters.pageSize === DEFAULT_PAGE_SIZE ? undefined : filters.pageSize,
}).filter(([, v]) => v),
),
});
try {
const res = await api<Paginated<AdminProduct>>("/admin/products", {
query: {
...filters,
q: filters.q || undefined,
pageSize: requestPageSize.value,
},
});
rows.value = res.items;
total.value = res.total;
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
async function loadGroups() {
groups.value = filters.categoryId
? (await api<AdminCategoryDetail>(`/admin/categories/${filters.categoryId}`)).groups
: [];
}
watch(
() => filters.categoryId,
async () => {
filters.groupId = undefined;
await loadGroups();
},
);
watch(
() => [filters.categoryId, filters.groupId, filters.status],
() => {
filters.page = 1;
void load();
},
);
async function togglePublish(row: AdminProduct, isPublished: boolean) {
try {
await api(`/admin/products/${row.id}/publish`, { method: "PATCH", body: { isPublished } });
row.isPublished = isPublished;
void message.success(isPublished ? "محصول منتشر شد" : "محصول از سایت مخفی شد");
} catch (e) {
void message.error(errorMessage(e));
}
}
async function duplicate(row: AdminProduct) {
try {
const copy = await api<{ id: number }>(`/admin/products/${row.id}/duplicate`, {
method: "POST",
});
void message.success("کپی پیش‌نویس ساخته شد");
await router.push({ name: "product-edit", params: { id: copy.id } });
} catch (e) {
void message.error(errorMessage(e));
}
}
async function remove(row: AdminProduct) {
try {
await api(`/admin/products/${row.id}`, { method: "DELETE" });
void message.success("محصول حذف شد");
await load();
} catch (e) {
void message.error(errorMessage(e));
}
}
async function move(index: number, d: -1 | 1) {
const list = [...rows.value];
const [row] = list.splice(index, 1);
list.splice(index + d, 0, row!);
rows.value = list;
try {
await api("/admin/products/reorder", { method: "POST", body: { ids: list.map((r) => r.id) } });
} catch (e) {
void message.error(errorMessage(e));
await load();
}
}
const columns = computed(() => [
{ title: "تصویر", key: "image", width: 76 },
{ title: "محصول", key: "name" },
{ title: "دسته / زیرگروه", key: "category", width: 200 },
{ title: "انتشار", key: "status", width: 90 },
...(reorderable.value ? [{ title: "ترتیب", key: "order", width: 100 }] : []),
{ title: "", key: "actions", width: 130 },
]);
watch([reorderable, loading, rows], async () => {
await nextTick();
measureTableScroll();
});
onMounted(async () => {
try {
categories.value = await api<AdminCategory[]>("/admin/categories");
await loadGroups();
} catch (e) {
void message.error(errorMessage(e));
}
await load();
await nextTick();
if (tableShell.value) {
tableResizeObserver = new ResizeObserver(() => measureTableScroll());
tableResizeObserver.observe(tableShell.value);
measureTableScroll();
}
});
onBeforeUnmount(() => {
tableResizeObserver?.disconnect();
tableResizeObserver = null;
});
</script>
<template>
<AdminPage lock-body>
<template #head>
<!-- Desktop Toolbar (lg and up) -->
<div class="toolbar !hidden lg:!flex">
<div class="toolbar__start">
<a-input-search
v-model:value="filters.q"
allow-clear
class="toolbar__search"
placeholder="جستجوی نام یا کد"
@search="
() => {
filters.page = 1;
load();
}
"
/>
<a-popover
v-model:open="filtersOpen"
trigger="click"
placement="bottomLeft"
:overlay-style="{ width: '280px' }"
>
<template #content>
<div class="filter-panel">
<a-select
v-model:value="filters.categoryId"
allow-clear
style="width: 100%"
placeholder="همه دسته‌ها"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
/>
<a-select
v-model:value="filters.groupId"
allow-clear
style="width: 100%"
placeholder="همه زیرگروه‌ها"
:disabled="!groups.length"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
<a-select
v-model:value="filters.status"
allow-clear
style="width: 100%"
placeholder="همه وضعیت‌ها"
:options="[
{ value: 'published', label: 'منتشرشده' },
{ value: 'draft', label: 'پیش‌نویس / مخفی' },
]"
/>
<p v-if="filters.categoryId && !reorderable" class="filter-panel__hint">
برای تغییر ترتیب، فقط فیلتر دسته را نگه دارید.
</p>
</div>
</template>
<a-badge :count="activeFilterCount" :offset="[-4, 4]" color="#ff5a00">
<a-button>
<template #icon><VuesaxIcon name="filter" :size="16" /></template>
فیلتر
</a-button>
</a-badge>
</a-popover>
</div>
<RouterLink
class="toolbar__new"
:to="{
name: 'product-new',
query: filters.categoryId ? { categoryId: filters.categoryId } : {},
}"
>
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</div>
<!-- Mobile Toolbar (Mobile Only) -->
<div class="mobile-products-toolbar lg:!hidden flex flex-col gap-2">
<div class="flex items-center gap-2 w-full">
<a-input-search
v-model:value="filters.q"
allow-clear
class="flex-1"
placeholder="جستجوی نام یا کد کالا..."
@search="
() => {
filters.page = 1;
load();
}
"
/>
<a-badge :count="activeFilterCount" :offset="[-2, 2]" color="#ff5a00">
<a-button
class="!h-9 !px-3 !rounded-lg !flex !items-center !gap-1.5"
:type="activeFilterCount > 0 ? 'primary' : 'default'"
:ghost="activeFilterCount > 0"
@click="mobileFilterDrawer = true"
>
<template #icon><VuesaxIcon name="filter" :size="16" /></template>
<span class="text-xs">فیلتر</span>
</a-button>
</a-badge>
</div>
<!-- Active Filter Chips on Mobile -->
<div
v-if="activeFilterCount > 0 || filters.q"
class="flex items-center gap-1.5 overflow-x-auto py-1 text-[11px] no-scrollbar"
>
<span
v-if="filters.q"
class="inline-flex items-center gap-1 bg-slate-100 text-slate-700 px-2 py-0.5 rounded-full shrink-0"
>
جستجو: «{{ filters.q }}»
<button type="button" class="text-slate-400 hover:text-slate-600" @click="clearSearch">✕</button>
</span>
<span
v-if="activeCategoryName"
class="inline-flex items-center gap-1 bg-orange-50 text-orange-700 border border-orange-200/60 px-2 py-0.5 rounded-full shrink-0"
>
{{ activeCategoryName }}
<button type="button" class="text-orange-400 hover:text-orange-700" @click="clearCategoryFilter">✕</button>
</span>
<span
v-if="activeGroupName"
class="inline-flex items-center gap-1 bg-blue-50 text-blue-700 border border-blue-200/60 px-2 py-0.5 rounded-full shrink-0"
>
{{ activeGroupName }}
<button type="button" class="text-blue-400 hover:text-blue-700" @click="clearGroupFilter">✕</button>
</span>
<span
v-if="filters.status"
class="inline-flex items-center gap-1 bg-slate-100 text-slate-700 px-2 py-0.5 rounded-full shrink-0"
>
{{ filters.status === 'published' ? 'منتشرشده' : 'پیش‌نویس / مخفی' }}
<button type="button" class="text-slate-400 hover:text-slate-700" @click="clearStatusFilter">✕</button>
</span>
<button
type="button"
class="text-[10px] text-rose-500 font-medium hover:underline shrink-0 px-1"
@click="clearAllFilters"
>
حذف همه
</button>
</div>
</div>
</template>
<!-- Desktop Table View (lg and up) -->
<div ref="tableShell" class="table-shell !hidden lg:!flex">
<a-table
class="table-shell__table"
:columns="columns"
:data-source="rows"
:loading="loading"
row-key="id"
:scroll="{ x: 760, y: scrollY }"
:pagination="tablePagination"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'image'">
<template v-if="thumbOf(record as AdminProduct).url">
<img
:src="thumbOf(record as AdminProduct).url!"
alt=""
:title="
thumbOf(record as AdminProduct).fallback
? 'این محصول عکس ندارد؛ کاور دسته نمایش داده شده'
: undefined
"
:class="[
'size-12 rounded bg-gray-50 object-contain',
thumbOf(record as AdminProduct).fallback && 'opacity-40 grayscale',
]"
/>
</template>
<div v-else class="size-12 rounded bg-gray-100" />
</template>
<template v-else-if="column.key === 'name'">
<RouterLink
:to="{ name: 'product-edit', params: { id: record.id } }"
class="font-bold text-[#0f172a] hover:text-[#ff5a00]"
>
{{ record.name }}
</RouterLink>
<div class="text-xs text-gray-400" dir="ltr">{{ record.code }}</div>
</template>
<template v-else-if="column.key === 'category'">
<div>{{ record.category.name }}</div>
<div v-if="record.group" class="text-xs text-gray-400">{{ record.group.name }}</div>
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.isPublished"
size="small"
@change="(v: unknown) => togglePublish(record as AdminProduct, !!v)"
/>
</template>
<template v-else-if="column.key === 'order'">
<a-space :size="4">
<a-button size="small" :disabled="index === 0" @click="move(index, -1)">
<template #icon><VuesaxIcon name="arrow-up" :size="14" /></template>
</a-button>
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
<template #icon><VuesaxIcon name="arrow-down" :size="14" /></template>
</a-button>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<RouterLink :to="{ name: 'product-edit', params: { id: record.id } }">
<a-button size="small" title="ویرایش"
><template #icon><VuesaxIcon name="edit-2" :size="14" /></template
></a-button>
</RouterLink>
<a-button size="small" title="کپی" @click="duplicate(record as AdminProduct)">
<template #icon><VuesaxIcon name="copy" :size="14" /></template>
</a-button>
<a-popconfirm
v-if="auth.isAdmin"
title="این محصول حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as AdminProduct)"
>
<a-button size="small" danger title="حذف"
><template #icon><VuesaxIcon name="trash" :size="14" /></template
></a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- Mobile Product Cards View (Mobile Only) -->
<div class="mobile-products-view block lg:!hidden pb-24">
<!-- Reorder Info Banner -->
<div
v-if="reorderable"
class="mb-3 p-2.5 rounded-xl bg-orange-50 border border-orange-200/70 text-xs text-orange-800 flex items-center gap-2"
>
<VuesaxIcon name="info-circle" :size="18" class="text-orange-600 shrink-0" />
<span>حالت تغییر ترتیب فعال است. می‌توانید با فلش‌ها ترتیب کالاها را عوض کنید.</span>
</div>
<!-- Count Header -->
<div class="flex items-center justify-between text-xs text-slate-500 px-1 mb-2.5">
<span>{{ total.toLocaleString("fa-IR") }} محصول</span>
<span v-if="!reorderable && totalPages > 1" class="font-mono">
صفحه {{ filters.page.toLocaleString("fa-IR") }} از {{ totalPages.toLocaleString("fa-IR") }}
</span>
</div>
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="محصولی با این مشخصات یافت نشد" class="py-12" />
<div v-else class="space-y-2.5">
<div
v-for="(record, index) in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3 shadow-2xs transition active:bg-slate-50/50"
>
<!-- Top: Thumbnail + Info -->
<div class="flex items-start gap-3">
<template v-if="thumbOf(record).url">
<img
:src="thumbOf(record).url!"
alt=""
:class="[
'size-14 rounded-lg bg-slate-50 object-contain shrink-0 border border-slate-100',
thumbOf(record).fallback && 'opacity-40 grayscale',
]"
/>
</template>
<div v-else class="size-14 rounded-lg bg-slate-100 shrink-0 flex items-center justify-center text-slate-300">
<VuesaxIcon name="gallery" :size="20" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<RouterLink
:to="{ name: 'product-edit', params: { id: record.id } }"
class="font-bold text-[13px] text-slate-800 hover:text-orange-600 line-clamp-2 leading-snug"
>
{{ record.name }}
</RouterLink>
<span class="text-[10px] font-mono text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded shrink-0" dir="ltr">
{{ record.code }}
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-1.5 text-[11px] text-slate-500">
<span class="text-slate-600 font-medium">{{ record.category.name }}</span>
<span v-if="record.group" class="text-slate-400">/</span>
<span v-if="record.group" class="text-slate-500">{{ record.group.name }}</span>
</div>
</div>
</div>
<!-- Bottom: Publish Status Switch + Touch Actions -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-2.5">
<div class="flex items-center gap-2">
<a-switch
:checked="record.isPublished"
size="small"
@change="(v: unknown) => togglePublish(record, !!v)"
/>
<span class="text-xs" :class="record.isPublished ? 'text-emerald-600 font-medium' : 'text-slate-400'">
{{ record.isPublished ? "منتشرشده" : "پیش‌نویس" }}
</span>
</div>
<div class="flex items-center gap-1.5">
<!-- Reorder buttons when reorderable -->
<template v-if="reorderable">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === 0"
@click="move(index, -1)"
>
<VuesaxIcon name="arrow-up" :size="16" />
</button>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === rows.length - 1"
@click="move(index, 1)"
>
<VuesaxIcon name="arrow-down" :size="16" />
</button>
</template>
<!-- Edit Action -->
<RouterLink :to="{ name: 'product-edit', params: { id: record.id } }">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="ویرایش"
>
<VuesaxIcon name="edit-2" :size="16" />
</button>
</RouterLink>
<!-- Duplicate Action -->
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="تکثیر / کپی"
@click="duplicate(record)"
>
<VuesaxIcon name="copy" :size="16" />
</button>
<!-- Delete Action -->
<a-popconfirm
v-if="auth.isAdmin"
title="این محصول حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 active:bg-rose-200 transition"
title="حذف"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
</div>
<!-- Mobile Pagination -->
<div v-if="!reorderable && totalPages > 1" class="flex items-center justify-between gap-3 pt-4 px-1">
<a-button
:disabled="filters.page <= 1"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(filters.page - 1)"
>
صفحه قبلی
</a-button>
<span class="text-xs text-slate-500 font-mono">
{{ filters.page.toLocaleString("fa-IR") }} / {{ totalPages.toLocaleString("fa-IR") }}
</span>
<a-button
:disabled="filters.page >= totalPages"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(filters.page + 1)"
>
صفحه بعدی
</a-button>
</div>
</a-spin>
<!-- Mobile Floating Action Button (FAB) for New Product -->
<RouterLink
:to="{
name: 'product-new',
query: filters.categoryId ? { categoryId: filters.categoryId } : {},
}"
class="fixed start-4 bottom-[72px] z-30 flex items-center gap-2 px-4 py-2.5 rounded-full bg-linear-to-r from-[#ff5a00] to-[#ff7a29] text-white font-bold text-xs shadow-lg shadow-orange-500/30 active:scale-95 transition"
>
<VuesaxIcon name="add" :size="18" />
<span>محصول جدید</span>
</RouterLink>
</div>
<!-- Mobile Filter Bottom Sheet Drawer -->
<a-drawer
v-model:open="mobileFilterDrawer"
placement="bottom"
height="auto"
:closable="false"
class="mobile-filter-drawer"
:body-style="{ padding: '16px 20px 24px', background: '#ffffff', borderTopLeftRadius: '20px', borderTopRightRadius: '20px' }"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between border-b border-slate-100 pb-3">
<div class="flex items-center gap-2">
<VuesaxIcon name="filter" :size="18" class="text-orange-600" />
<span class="text-sm font-bold text-slate-800">فیلترهای کاتالوگ</span>
</div>
<button
type="button"
class="text-xs text-slate-400 hover:text-slate-600"
@click="mobileFilterDrawer = false"
>
بستن
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">دسته‌بندی</label>
<a-select
v-model:value="filters.categoryId"
allow-clear
size="large"
style="width: 100%"
placeholder="همه دسته‌ها"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">زیرگروه کالا</label>
<a-select
v-model:value="filters.groupId"
allow-clear
size="large"
style="width: 100%"
placeholder="همه زیرگروه‌ها"
:disabled="!groups.length"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">وضعیت انتشار</label>
<a-select
v-model:value="filters.status"
allow-clear
size="large"
style="width: 100%"
placeholder="همه وضعیت‌ها"
:options="[
{ value: 'published', label: 'منتشرشده در سایت' },
{ value: 'draft', label: 'پیش‌نویس / مخفی' },
]"
/>
</div>
</div>
<div class="flex items-center gap-2.5 pt-2 border-t border-slate-100">
<a-button
v-if="activeFilterCount > 0"
danger
class="!h-11 !rounded-xl"
@click="clearAllFilters(); mobileFilterDrawer = false;"
>
حذف فیلترها
</a-button>
<a-button
type="primary"
block
class="!h-11 !rounded-xl !font-bold"
@click="mobileFilterDrawer = false; load();"
>
اعمال فیلترها
</a-button>
</div>
</div>
</a-drawer>
</AdminPage>
</template>
<style scoped>
.table-shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.table-shell :deep(.ant-table-wrapper),
.table-shell :deep(.ant-spin-nested-loading),
.table-shell :deep(.ant-spin-container) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.table-shell :deep(.ant-table) {
flex: 1;
min-height: 0;
}
.table-shell :deep(.ant-table-pagination) {
flex-shrink: 0;
margin: 12px 0 0 !important;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.toolbar__start {
display: flex;
flex: 1;
align-items: center;
gap: 16px;
min-width: 0;
}
.toolbar__search {
flex: 1;
min-width: 200px;
max-width: 360px;
}
.toolbar__new {
margin-inline-start: auto;
}
.filter-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.filter-panel__hint {
margin: 0;
font-size: 12px;
color: #94a3b8;
line-height: 1.6;
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { changePasswordSchema, ROLE_LABELS } from "@imen/shared";
import { message } from "ant-design-vue";
import { ref } from "vue";
import { api } from "@/api/http";
import FormField from "@/components/FormField.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const { model, validate, handleError, reset } = useZodForm(changePasswordSchema, {
currentPassword: "",
newPassword: "",
});
const saving = ref(false);
async function save() {
const data = validate();
if (!data) return;
saving.value = true;
try {
await api("/admin/auth/change-password", { method: "POST", body: data });
void message.success("رمز عبور تغییر کرد");
reset({ currentPassword: "", newPassword: "" });
} catch (e) {
handleError(e);
} finally {
saving.value = false;
}
}
</script>
<template>
<PageHeader title="حساب کاربری" subtitle="مشخصات فردی، نقش دسترسی و تغییر رمز عبور ورود به سامانه" />
<div class="grid max-w-4xl gap-4 md:gap-6 md:grid-cols-2">
<!-- User Identity Card -->
<a-card title="مشخصات کاربری">
<div class="flex items-center gap-3.5 mb-4 pb-4 border-b border-gray-100">
<div class="flex size-14 items-center justify-center rounded-2xl bg-gradient-to-tr from-[#ff5a00] to-amber-500 text-xl font-black text-white shadow-md shadow-orange-500/20">
{{ auth.user?.name?.slice(0, 1) || 'U' }}
</div>
<div>
<div class="text-base font-bold text-gray-900">{{ auth.user?.name }}</div>
<div class="mt-0.5 text-xs font-mono text-gray-500" dir="ltr">{{ auth.user?.phone }}</div>
</div>
</div>
<a-descriptions :column="1" size="small">
<a-descriptions-item label="نام">{{ auth.user?.name }}</a-descriptions-item>
<a-descriptions-item label="موبایل"><span dir="ltr" class="font-mono">{{ auth.user?.phone }}</span></a-descriptions-item>
<a-descriptions-item label="نقش دسترسی">
<a-tag :color="auth.user?.role === 'ADMIN' ? 'orange' : 'blue'">
{{ auth.user && ROLE_LABELS[auth.user.role] }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- Change Password Card -->
<a-card title="تغییر رمز عبور">
<a-form layout="vertical" @submit.prevent="save">
<FormField path="currentPassword" label="رمز فعلی" required>
<a-input-password v-model:value="model.currentPassword" class="ltr-input h-10" autocomplete="current-password" />
</FormField>
<FormField path="newPassword" label="رمز جدید" hint="حداقل ۸ کاراکتر" required>
<a-input-password v-model:value="model.newPassword" class="ltr-input h-10" autocomplete="new-password" />
</FormField>
<a-button type="primary" html-type="submit" :loading="saving" class="w-full md:w-auto h-10 !font-bold">
تغییر رمز ورود
</a-button>
</a-form>
</a-card>
</div>
</template>
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { isSectionKey, SECTION_LABELS, type SectionKey, sectionSchemas } from "@imen/shared";
import { message } from "ant-design-vue";
import { type Component, computed, ref } from "vue";
import { useRoute } from "vue-router";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { SectionResponse } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
import { jalali, SITE_URL } from "@/utils/format";
import AboutForm from "@/sections/AboutForm.vue";
import ContactForm from "@/sections/ContactForm.vue";
import FooterForm from "@/sections/FooterForm.vue";
import HeroForm from "@/sections/HeroForm.vue";
import IndustriesForm from "@/sections/IndustriesForm.vue";
import ProductsIntroForm from "@/sections/ProductsIntroForm.vue";
import ServicesForm from "@/sections/ServicesForm.vue";
import SettingsForm from "@/sections/SettingsForm.vue";
import StandardsForm from "@/sections/StandardsForm.vue";
import StatsForm from "@/sections/StatsForm.vue";
import TapeForm from "@/sections/TapeForm.vue";
import TestimonialsForm from "@/sections/TestimonialsForm.vue";
const FORMS: Record<SectionKey, Component> = {
settings: SettingsForm,
hero: HeroForm,
tape: TapeForm,
stats: StatsForm,
productsIntro: ProductsIntroForm,
about: AboutForm,
services: ServicesForm,
standards: StandardsForm,
industries: IndustriesForm,
testimonials: TestimonialsForm,
contact: ContactForm,
footer: FooterForm,
};
const auth = useAuthStore();
const route = useRoute();
const key = String(route.params.key);
const valid = isSectionKey(key);
const sectionKey = (valid ? key : "hero") as SectionKey;
// the page is re-created per key (RouterView keys by path), so one form instance per section
const { model, validate, handleError, reset } = useZodForm(
sectionSchemas[sectionKey] as (typeof sectionSchemas)["hero"],
{} as never,
);
const loaded = ref(false);
const saving = ref(false);
const updatedAt = ref<string | null>(null);
const label = computed(() => (valid ? SECTION_LABELS[sectionKey] : null));
async function load() {
try {
const res = await api<SectionResponse>(`/admin/sections/${sectionKey}`);
const data = res.data as any;
if (sectionKey !== "settings" && data.enabled === undefined) {
data.enabled = true;
}
reset(data as never);
updatedAt.value = res.updatedAt;
loaded.value = true;
} catch (e) {
void message.error(errorMessage(e));
}
}
async function save() {
const data = validate();
if (!data) return;
saving.value = true;
try {
const res = await api<SectionResponse>(`/admin/sections/${sectionKey}`, { method: "PUT", body: data });
reset(res.data as never);
updatedAt.value = res.updatedAt;
void message.success("ذخیره شد؛ سایت تا چند لحظه دیگر به‌روز می‌شود");
} catch (e) {
handleError(e);
} finally {
saving.value = false;
}
}
const togglingVisibility = ref(false);
async function onVisibilityChange(val: any) {
const nextVal = Boolean(val);
(model.value as any).enabled = nextVal;
togglingVisibility.value = true;
try {
const res = await api<{ key: string; enabled: boolean; updatedAt: string }>(
`/admin/sections/${sectionKey}/visibility`,
{
method: "PATCH",
body: { enabled: nextVal },
},
);
(model.value as any).enabled = res.enabled !== false;
updatedAt.value = res.updatedAt;
void message.success(
`وضعیت نمایش به ${nextVal ? "نمایش" : "عدم نمایش"} تغییر یافت`,
);
} catch (e) {
(model.value as any).enabled = !nextVal;
void message.error(errorMessage(e));
} finally {
togglingVisibility.value = false;
}
}
if (valid) void load();
</script>
<template>
<a-result v-if="!valid" status="404" title="بخش پیدا نشد" />
<template v-else>
<PageHeader
:title="label!.title"
:subtitle="updatedAt ? `آخرین ذخیره: ${jalali(updatedAt)}` : label!.desc"
back="/sections"
>
<div v-if="sectionKey !== 'settings'" class="max-lg:hidden flex items-center gap-2 me-2">
<span class="text-sm font-medium text-gray-700">نمایش در سایت:</span>
<a-switch
:checked="(model as any)?.enabled !== false"
:loading="togglingVisibility"
checked-children="نمایش"
un-checked-children="مخفی"
@change="onVisibilityChange"
/>
</div>
<a :href="SITE_URL" target="_blank" rel="noopener" class="max-lg:hidden"><a-button>مشاهده سایت</a-button></a>
<a-button
type="primary"
class="max-lg:hidden"
:loading="saving"
:disabled="!loaded || (sectionKey === 'settings' && !auth.isAdmin)"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</PageHeader>
<a-spin :spinning="!loaded">
<a-alert
v-if="loaded && sectionKey === 'settings' && !auth.isAdmin"
type="error"
show-icon
message="فقط مدیر کل می‌تواند تنظیمات سایت را تغییر دهد."
class="mb-4"
/>
<a-alert
v-else-if="loaded && sectionKey !== 'settings' && (model as any).enabled === false"
type="warning"
show-icon
message="این بخش در حال حاضر در سایت نمایش داده نمی‌شود (مخفی است)."
class="mb-4"
/>
<a-form v-if="loaded" layout="vertical" class="pb-20 lg:pb-0" @submit.prevent="save">
<component :is="FORMS[sectionKey]" v-model="model" />
</a-form>
</a-spin>
<!-- Mobile Sticky Bottom Action Bar -->
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-2.5 backdrop-blur-md shadow-lg flex items-center justify-between gap-3">
<div v-if="sectionKey !== 'settings'" class="flex items-center gap-2">
<a-switch
:checked="(model as any)?.enabled !== false"
:loading="togglingVisibility"
size="small"
@change="onVisibilityChange"
/>
<span class="text-xs font-medium text-gray-700">
{{ (model as any)?.enabled !== false ? 'نمایش در سایت' : 'مخفی' }}
</span>
</div>
<div v-else class="text-xs text-gray-500 font-medium">تنظیمات پایه</div>
<div class="flex items-center gap-2">
<a :href="SITE_URL" target="_blank" rel="noopener">
<a-button size="middle" class="!px-3" title="مشاهده سایت">
<template #icon><VuesaxIcon name="eye" :size="16" /></template>
</a-button>
</a>
<a-button
type="primary"
size="middle"
class="!font-bold !px-5"
:loading="saving"
:disabled="!loaded || (sectionKey === 'settings' && !auth.isAdmin)"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره تغییرات
</a-button>
</div>
</div>
</template>
</template>
+158
View File
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { SECTION_KEYS, SECTION_LABELS, type SectionKey } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { api, errorMessage } from "@/api/http";
import type { AdminSectionSummary } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { jalali } from "@/utils/format";
const router = useRouter();
const auth = useAuthStore();
// site-wide settings are editable by admins only
const keys = SECTION_KEYS.filter((k) => k !== "settings" || auth.isAdmin);
const loading = ref(true);
const sectionEnabled = reactive<Record<string, boolean>>({
hero: true,
tape: true,
stats: true,
productsIntro: true,
about: true,
services: true,
standards: true,
industries: true,
testimonials: true,
contact: true,
footer: true,
});
const sectionUpdatedAt = reactive<Record<string, string | null>>({});
const toggling = reactive<Record<string, boolean>>({});
function isEnabled(key: SectionKey): boolean {
return sectionEnabled[key] !== false;
}
function getUpdatedAt(key: SectionKey): string | null {
return sectionUpdatedAt[key] ?? null;
}
async function fetchSections() {
loading.value = true;
try {
const list = await api<AdminSectionSummary[]>("/admin/sections");
for (const item of list) {
sectionEnabled[item.key] = item.enabled !== false;
sectionUpdatedAt[item.key] = item.updatedAt;
}
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
async function toggle(key: SectionKey, nextVal: boolean) {
if (key === "settings") return;
const prevVal = sectionEnabled[key] !== false;
sectionEnabled[key] = nextVal;
toggling[key] = true;
try {
const res = await api<{ key: string; enabled: boolean; updatedAt: string }>(
`/admin/sections/${key}/visibility`,
{
method: "PATCH",
body: { enabled: nextVal },
},
);
sectionEnabled[key] = res.enabled !== false;
sectionUpdatedAt[key] = res.updatedAt;
void message.success(
`وضعیت «${SECTION_LABELS[key].title}» به ${nextVal ? "نمایش" : "عدم نمایش"} تغییر یافت`,
);
} catch (e) {
sectionEnabled[key] = prevVal;
void message.error(errorMessage(e));
} finally {
toggling[key] = false;
}
}
function goToEdit(key: SectionKey) {
void router.push({ name: "section-edit", params: { key } });
}
onMounted(() => {
void fetchSections();
});
</script>
<template>
<PageHeader
title="بخش‌های سایت"
subtitle="متن‌ها، تصاویر و فهرست‌های صفحه اصلی؛ تنظیم وضعیت نمایش و ویرایش محتوا"
/>
<a-spin :spinning="loading">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="key in keys"
:key="key"
class="block group"
>
<a-card
hoverable
class="h-full flex flex-col justify-between transition-all"
:class="{ 'opacity-75 bg-gray-50/60': key !== 'settings' && !isEnabled(key) }"
>
<div>
<div class="flex items-center justify-between gap-2">
<div
class="flex items-center gap-2 cursor-pointer flex-1"
@click="goToEdit(key)"
>
<span class="font-bold text-gray-800 group-hover:text-orange-600 transition-colors">
{{ SECTION_LABELS[key].title }}
</span>
<a-tag v-if="key === 'settings'" color="blue">تنظیمات پایه</a-tag>
<a-tag v-else-if="!isEnabled(key)" color="error">مخفی</a-tag>
</div>
<!-- Switch toggle: standalone button without any outer anchor tag -->
<div
v-if="key !== 'settings'"
class="flex items-center flex-shrink-0"
>
<a-switch
:checked="isEnabled(key)"
:loading="toggling[key]"
checked-children="نمایش"
un-checked-children="مخفی"
@change="(val: any) => toggle(key, Boolean(val))"
/>
</div>
</div>
<p
class="mt-2 text-sm leading-6 text-gray-500 cursor-pointer"
@click="goToEdit(key)"
>
{{ SECTION_LABELS[key].desc }}
</p>
</div>
<div
class="mt-4 flex items-center justify-between border-t border-gray-100 pt-3 text-xs text-gray-400 cursor-pointer"
@click="goToEdit(key)"
>
<span class="text-primary font-medium group-hover:underline">ویرایش بخش ←</span>
<span v-if="getUpdatedAt(key)">آخرین تغییر: {{ jalali(getUpdatedAt(key)) }}</span>
</div>
</a-card>
</div>
</div>
</a-spin>
</template>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
import {
type AdminUserDto,
ROLE_LABELS,
ROLES,
toIssues,
userCreateSchema,
userUpdateSchema,
} from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, type Ref, ref } from "vue";
import type { z } from "zod";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import FormField from "@/components/FormField.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
import { jalali } from "@/utils/format";
const auth = useAuthStore();
const rows = ref<AdminUserDto[]>([]);
const loading = ref(false);
/** null = closed, 0 = new user, id = editing */
const editing = ref<number | null>(null);
const saving = ref(false);
// one form model for both create and update; the matching schema validates on save
type Form = z.input<typeof userCreateSchema> & { isActive: boolean };
const empty: Form = { phone: "", name: "", role: "EDITOR", password: "", isActive: true };
const form = useZodForm(userCreateSchema, empty);
const model = form.model as unknown as Ref<Form>;
const isNew = computed(() => editing.value === 0);
const ROLE_OPTIONS = ROLES.map((r) => ({ value: r, label: ROLE_LABELS[r] }));
async function load() {
loading.value = true;
try {
rows.value = await api<AdminUserDto[]>("/admin/users");
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
function openNew() {
form.reset(empty);
editing.value = 0;
}
function openEdit(u: AdminUserDto) {
form.reset({ phone: u.phone, name: u.name, role: u.role, isActive: u.isActive, password: "" } as Form);
editing.value = u.id;
}
async function save() {
saving.value = true;
try {
if (isNew.value) {
const data = form.validate();
if (!data) return;
await api("/admin/users", { method: "POST", body: data });
} else {
const { name, role, isActive, password } = model.value;
const parsed = userUpdateSchema.safeParse({ name, role, isActive, password: password || undefined });
if (!parsed.success) {
form.errors.value = Object.fromEntries(toIssues(parsed.error).map((i) => [i.path, i.message]));
return;
}
await api(`/admin/users/${editing.value}`, { method: "PUT", body: parsed.data });
}
void message.success("ذخیره شد");
editing.value = null;
await load();
} catch (e) {
form.handleError(e);
} finally {
saving.value = false;
}
}
async function remove(u: AdminUserDto) {
try {
await api(`/admin/users/${u.id}`, { method: "DELETE" });
void message.success("کاربر حذف شد");
await load();
} catch (e) {
void message.error(errorMessage(e));
}
}
const columns = [
{ title: "نام", dataIndex: "name" },
{ title: "موبایل", key: "phone" },
{ title: "نقش", key: "role", width: 110 },
{ title: "وضعیت", key: "active", width: 100 },
{ title: "آخرین ورود", key: "login", width: 170 },
{ title: "", key: "actions", width: 100 },
];
onMounted(load);
</script>
<template>
<PageHeader title="کاربران پنل" subtitle="مدیر کل به همه بخش‌ها دسترسی دارد؛ ویرایشگر به جز کاربران و تنظیمات سایت">
<a-button type="primary" @click="openNew">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
کاربر جدید
</a-button>
</PageHeader>
<!-- Desktop Table (Preserved for lg+) -->
<a-card :body-style="{ padding: 0 }" class="max-lg:hidden">
<a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 700 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'phone'"><span dir="ltr">{{ record.phone }}</span></template>
<template v-else-if="column.key === 'role'">
<a-tag :color="record.role === 'ADMIN' ? 'orange' : 'blue'">{{ ROLE_LABELS[record.role as keyof typeof ROLE_LABELS] }}</a-tag>
</template>
<template v-else-if="column.key === 'active'">
<a-tag :color="record.isActive ? 'green' : 'default'">{{ record.isActive ? "فعال" : "غیرفعال" }}</a-tag>
</template>
<template v-else-if="column.key === 'login'">{{ jalali(record.lastLoginAt) }}</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button size="small" @click="openEdit(record as AdminUserDto)"><template #icon><VuesaxIcon name="edit-2" :size="16" /></template></a-button>
<a-popconfirm
v-if="record.id !== auth.user?.id"
title="این کاربر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as AdminUserDto)"
>
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- Mobile User Cards (App Experience for <lg) -->
<div class="lg:hidden space-y-3">
<a-spin :spinning="loading">
<div v-if="!rows.length && !loading" class="rounded-xl border border-dashed border-gray-200 bg-white p-8 text-center">
<div class="text-sm text-gray-500">کاربری یافت نشد</div>
</div>
<div v-else class="space-y-3">
<div
v-for="user in rows"
:key="user.id"
class="rounded-xl border border-gray-100 bg-white p-4 shadow-sm transition-all"
>
<!-- Header: User Avatar & Role Tag -->
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3">
<div
class="flex size-11 flex-shrink-0 items-center justify-center rounded-full font-bold text-white shadow-sm"
:class="user.role === 'ADMIN' ? 'bg-gradient-to-tr from-orange-600 to-amber-500' : 'bg-gradient-to-tr from-blue-600 to-sky-500'"
>
{{ user.name.slice(0, 1) }}
</div>
<div>
<div class="font-bold text-gray-900">{{ user.name }}</div>
<a
:href="`tel:${user.phone}`"
class="mt-0.5 inline-block text-xs font-mono text-gray-500 hover:text-orange-600"
dir="ltr"
>
{{ user.phone }}
</a>
</div>
</div>
<div class="flex flex-col items-end gap-1">
<a-tag :color="user.role === 'ADMIN' ? 'orange' : 'blue'" class="!m-0 text-[11px]">
{{ ROLE_LABELS[user.role as keyof typeof ROLE_LABELS] }}
</a-tag>
<a-tag :color="user.isActive ? 'green' : 'default'" class="!m-0 text-[10px]">
{{ user.isActive ? "فعال" : "غیرفعال" }}
</a-tag>
</div>
</div>
<!-- Bottom Meta & Actions -->
<div class="mt-3.5 flex items-center justify-between border-t border-gray-100 pt-3 text-xs text-gray-400">
<div class="flex items-center gap-1.5">
<span>آخرین ورود:</span>
<span class="font-medium text-gray-600">{{ jalali(user.lastLoginAt) }}</span>
</div>
<div class="flex items-center gap-1.5">
<a-button size="small" class="!flex !items-center !gap-1" @click="openEdit(user)">
<template #icon><VuesaxIcon name="edit-2" :size="14" /></template>
<span>ویرایش</span>
</a-button>
<a-popconfirm
v-if="user.id !== auth.user?.id"
title="این کاربر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(user)"
>
<a-button size="small" danger class="!px-2">
<template #icon><VuesaxIcon name="trash" :size="14" /></template>
</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</a-spin>
</div>
<!-- Mobile Floating Action Button (FAB) for New User -->
<button
type="button"
class="lg:hidden fixed bottom-20 left-4 z-30 flex size-13 items-center justify-center rounded-full bg-[#ff5a00] text-white shadow-lg shadow-orange-500/30 transition-transform active:scale-95"
title="کاربر جدید"
@click="openNew"
>
<VuesaxIcon name="add" :size="24" />
</button>
<!-- Mobile Collapsible Role Permissions Guide -->
<details class="lg:hidden mt-4 rounded-xl border border-gray-200 bg-white p-3 text-xs shadow-sm">
<summary class="cursor-pointer font-bold text-gray-700 flex items-center justify-between select-none">
<span class="flex items-center gap-1.5">
<VuesaxIcon name="info-circle" :size="16" class="text-orange-500" />
راهنمای نقش‌ها و مجوزهای دسترسی
</span>
<span class="text-gray-400 text-[10px]">لمس برای مشاهده</span>
</summary>
<div class="mt-3 space-y-3 pt-3 border-t border-gray-100">
<div class="rounded-lg border border-orange-200 bg-orange-50/40 p-2.5">
<div class="flex items-center gap-2 font-bold text-orange-700">
<a-tag color="orange">مدیر کل (ADMIN)</a-tag>
<span class="text-[11px]">دسترسی ارشد سیستمی</span>
</div>
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
<li>مدیریت کاربران، نقش‌ها و تنظیمات امنیتی</li>
<li>تنظیمات پایه سایت، سئو، شبکه‌ها و ساعات کاری</li>
<li>حذف داده‌ها (محصولات، دسته‌ها، پیام‌ها، رسانه‌ها)</li>
</ul>
</div>
<div class="rounded-lg border border-blue-200 bg-blue-50/40 p-2.5">
<div class="flex items-center gap-2 font-bold text-blue-700">
<a-tag color="blue">ویرایشگر (EDITOR)</a-tag>
<span class="text-[11px]">دسترسی محتوایی</span>
</div>
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
<li>ایجاد، ویرایش و انتشار محصولات و دسته‌بندی‌ها</li>
<li>ویرایش محتوای بخش‌های صفحه اصلی</li>
<li>آپلود تصاویر در رسانه و مشاهده پیام‌های تماس</li>
</ul>
</div>
</div>
</details>
<!-- Desktop Role Permissions Reference Cards -->
<div class="mt-6 max-lg:hidden grid gap-4 md:grid-cols-2">
<a-card size="small" class="border-orange-200 bg-orange-50/30">
<div class="flex items-center gap-2 font-bold text-orange-700">
<a-tag color="orange">مدیر کل (ADMIN)</a-tag>
<span class="text-xs">دسترسی ارشد سیستمی</span>
</div>
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
<li>مدیریت کاربران، افزودن کاربر و تغییر نقش‌ها و وضعیت حساب</li>
<li>دسترسی کامل به تنظیمات پایه سایت، ساعات کاری، شبکه‌ها و سئو</li>
<li>مجوز حذف محصولات، دسته‌بندی‌ها، رسانه‌ها و پیام‌های تماس</li>
<li>مشاهده لاگ‌های امنیتی و رویدادهای کامل سامانه</li>
</ul>
</a-card>
<a-card size="small" class="border-blue-200 bg-blue-50/30">
<div class="flex items-center gap-2 font-bold text-blue-700">
<a-tag color="blue">ویرایشگر (EDITOR)</a-tag>
<span class="text-xs">دسترسی محتوایی</span>
</div>
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
<li>ایجاد، ویرایش، کپی و انتشار محصولات و گروه‌ها</li>
<li>ویرایش محتوای بخش‌های صفحه اصلی و فعال/مخفی‌سازی نمایش آنها</li>
<li>آپلود و ویرایش اطلاعات رسانه‌ها در کتابخانه رسانه</li>
<li>مشاهده پیام‌های تماس و به‌روزرسانی وضعیت (بدون دسترسی حذف یا مدیریت کاربران)</li>
</ul>
</a-card>
</div>
<a-modal
:open="editing !== null"
:title="isNew ? 'کاربر جدید' : 'ویرایش کاربر'"
ok-text="ذخیره"
cancel-text="انصراف"
:confirm-loading="saving"
destroy-on-close
@ok="save"
@cancel="editing = null"
>
<a-form layout="vertical" class="mt-4" @submit.prevent="save">
<FormField path="phone" label="شماره موبایل" required>
<a-input
v-model:value="model.phone"
class="ltr-input"
:disabled="!isNew"
inputmode="tel"
placeholder="09121234567"
/>
</FormField>
<FormField path="name" label="نام" required>
<a-input v-model:value="model.name" />
</FormField>
<FormField path="role" label="نقش" required>
<a-select
v-model:value="model.role"
:disabled="editing === auth.user?.id"
:options="ROLE_OPTIONS"
/>
<div class="mt-1.5 text-[11px] text-gray-500">
{{ model.role === 'ADMIN' ? 'مدیر کل: دسترسی کامل سیستمی، حذف داده‌ها، تنظیمات و مدیریت کاربران' : 'ویرایشگر: ویرایش محتوا، محصولات، بخش‌ها و پیام‌ها (بدون حذف)' }}
</div>
</FormField>
<FormField v-if="!isNew" path="isActive">
<a-checkbox v-model:checked="model.isActive" :disabled="editing === auth.user?.id">حساب فعال است</a-checkbox>
</FormField>
<FormField path="password" :label="isNew ? 'رمز عبور' : 'رمز عبور جدید (اختیاری)'" :required="isNew" hint="حداقل ۸ کاراکتر">
<a-input-password v-model:value="model.password" class="ltr-input" autocomplete="new-password" />
</FormField>
</a-form>
</a-modal>
</template>
+57
View File
@@ -0,0 +1,57 @@
import type { Role } from "@imen/shared";
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "@/stores/auth";
declare module "vue-router" {
interface RouteMeta {
title?: string;
public?: boolean;
roles?: Role[];
}
}
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/login",
name: "login",
component: () => import("@/pages/LoginPage.vue"),
meta: { public: true, title: "ورود" },
},
{
path: "/",
component: () => import("@/layouts/AdminLayout.vue"),
children: [
{ path: "", name: "dashboard", component: () => import("@/pages/DashboardPage.vue"), meta: { title: "داشبورد" } },
{ path: "products", name: "products", component: () => import("@/pages/ProductsPage.vue"), meta: { title: "محصولات" } },
{ path: "products/new", name: "product-new", component: () => import("@/pages/ProductEditPage.vue"), meta: { title: "محصول جدید" } },
{ path: "products/:id", name: "product-edit", component: () => import("@/pages/ProductEditPage.vue"), meta: { title: "ویرایش محصول" } },
{ path: "categories", name: "categories", component: () => import("@/pages/CategoriesPage.vue"), meta: { title: "دسته‌بندی‌ها" } },
{ path: "categories/new", name: "category-new", component: () => import("@/pages/CategoryEditPage.vue"), meta: { title: "دسته جدید" } },
{ path: "categories/:id", name: "category-edit", component: () => import("@/pages/CategoryEditPage.vue"), meta: { title: "ویرایش دسته" } },
{ path: "sections", name: "sections", component: () => import("@/pages/SectionsPage.vue"), meta: { title: "بخش‌های سایت" } },
{ path: "sections/:key", name: "section-edit", component: () => import("@/pages/SectionEditPage.vue"), meta: { title: "ویرایش بخش" } },
{ path: "messages", name: "messages", component: () => import("@/pages/MessagesPage.vue"), meta: { title: "پیام‌های تماس" } },
{ path: "media", name: "media", component: () => import("@/pages/MediaPage.vue"), meta: { title: "کتابخانه رسانه" } },
{ path: "users", name: "users", component: () => import("@/pages/UsersPage.vue"), meta: { title: "کاربران", roles: ["ADMIN"] } },
{ path: "profile", name: "profile", component: () => import("@/pages/ProfilePage.vue"), meta: { title: "حساب کاربری" } },
],
},
{ path: "/:pathMatch(.*)*", redirect: "/" },
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
await auth.init();
if (to.meta.public) return auth.user && to.name === "login" ? { name: "dashboard" } : true;
if (!auth.user) return { name: "login", query: { redirect: to.fullPath } };
if (to.meta.roles && !to.meta.roles.includes(auth.user.role)) return { name: "dashboard" };
return true;
});
router.afterEach((to) => {
document.title = to.meta.title ? `${to.meta.title} | پنل ایمن پیشگامان` : "پنل ایمن پیشگامان";
});
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import IconSelect from "@/components/IconSelect.vue";
import ImageRefField from "@/components/ImageRefField.vue";
import ListEditor from "@/components/ListEditor.vue";
import SectionHeadFields from "@/components/SectionHeadFields.vue";
const data = defineModel<SectionData<"about">>({ required: true });
</script>
<template>
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<a-card title="عنوان و متن">
<SectionHeadFields v-model="data.head" :with-desc="false" />
<FormField path="body" label="متن درباره ما" required>
<a-textarea v-model:value="data.body" :auto-size="{ minRows: 4, maxRows: 10 }" />
</FormField>
</a-card>
<a-card title="تایم‌لاین" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.timeline" :create="() => ({ year: '', text: '' })" :max="12" add-label="افزودن رویداد" :item-title="(t) => t.year">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-4">
<FormField :path="`timeline.${index}.year`" label="سال" required><a-input v-model:value="item.year" /></FormField>
<FormField :path="`timeline.${index}.text`" label="رویداد" required class="md:col-span-3"><a-input v-model:value="item.text" /></FormField>
</div>
</template>
</ListEditor>
</a-card>
<a-card title="ویژگی‌ها" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.features" :create="() => ({ icon: 'ShieldCheck', label: '' })" :max="6" add-label="افزودن ویژگی" :item-title="(f) => f.label">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`features.${index}.label`" label="متن" required><a-input v-model:value="item.label" /></FormField>
<FormField :path="`features.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
</div>
</template>
</ListEditor>
</a-card>
</div>
<div class="space-y-6">
<a-card title="تصویر">
<ImageRefField v-model="data.image" path="image" label="تصویر" />
</a-card>
<a-card title="نشان روی تصویر">
<FormField path="badge.value" label="عدد" required><a-input v-model:value="data.badge.value" /></FormField>
<FormField path="badge.label" label="متن" hint="برای شکستن خط Enter بزنید" required>
<a-textarea v-model:value="data.badge.label" :auto-size="{ minRows: 2 }" />
</FormField>
</a-card>
</div>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import SectionHeadFields from "@/components/SectionHeadFields.vue";
const data = defineModel<SectionData<"contact">>({ required: true });
</script>
<template>
<div class="space-y-6">
<a-card title="عنوان بخش">
<SectionHeadFields v-model="data.head" />
<p class="text-sm text-gray-500">نشانی، تلفن، ایمیل و ساعات کاری از «تنظیمات سایت» خوانده می‌شوند.</p>
</a-card>
<div class="grid gap-6 lg:grid-cols-2">
<a-card title="کادر پاسخ‌گویی اضطراری">
<FormField path="emergencyTitle" label="عنوان" required><a-input v-model:value="data.emergencyTitle" /></FormField>
<FormField path="emergencyText" label="متن" required>
<a-textarea v-model:value="data.emergencyText" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
</a-card>
<a-card title="فرم">
<FormField path="formTag" label="برچسب بالای فرم"><a-input v-model:value="data.formTag" /></FormField>
<FormField path="formTitle" label="عنوان فرم" hint="برای شکستن خط Enter بزنید" required>
<a-textarea v-model:value="data.formTitle" :auto-size="{ minRows: 2 }" />
</FormField>
<FormField path="successTitle" label="عنوان پیام موفقیت" required><a-input v-model:value="data.successTitle" /></FormField>
<FormField path="successText" label="متن پیام موفقیت" required>
<a-textarea v-model:value="data.successText" :auto-size="{ minRows: 2 }" />
</FormField>
</a-card>
</div>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
const data = defineModel<SectionData<"footer">>({ required: true });
</script>
<template>
<a-card title="فوتر">
<FormField path="about" label="متن معرفی" required>
<a-textarea v-model:value="data.about" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
<div class="grid gap-x-4 md:grid-cols-3">
<FormField path="copyright" label="متن کپی‌رایت" required><a-input v-model:value="data.copyright" /></FormField>
<FormField path="certLine" label="خط گواهی‌نامه‌ها"><a-input v-model:value="data.certLine" class="ltr-input" /></FormField>
<FormField path="emergencyNote" label="یادداشت پشتیبانی"><a-input v-model:value="data.emergencyNote" /></FormField>
</div>
<p class="text-sm text-gray-500">لینک دسته‌ها و اطلاعات تماس فوتر به‌صورت خودکار از دسته‌بندی‌ها و تنظیمات سایت ساخته می‌شوند.</p>
</a-card>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import IconSelect from "@/components/IconSelect.vue";
import ImageRefField from "@/components/ImageRefField.vue";
import ListEditor from "@/components/ListEditor.vue";
const data = defineModel<SectionData<"hero">>({ required: true });
</script>
<template>
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<a-card title="متن‌ها">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="badge" label="نشان بالای تیتر" required class="md:col-span-2">
<a-input v-model:value="data.badge" />
</FormField>
<FormField path="title" label="تیتر (خط اول)" required>
<a-input v-model:value="data.title" />
</FormField>
<FormField path="titleHighlight" label="تیتر (خط زرد)" required>
<a-input v-model:value="data.titleHighlight" />
</FormField>
<FormField path="intro" label="متن معرفی" required class="md:col-span-2">
<a-textarea v-model:value="data.intro" :auto-size="{ minRows: 3, maxRows: 6 }" />
</FormField>
</div>
</a-card>
<a-card title="دکمه‌ها">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="primaryCta.label" label="دکمه اصلی — متن" required>
<a-input v-model:value="data.primaryCta.label" />
</FormField>
<FormField path="primaryCta.href" label="دکمه اصلی — لینک" hint="مثلاً #contact یا /categories/..." required>
<a-input v-model:value="data.primaryCta.href" class="ltr-input" />
</FormField>
<FormField path="secondaryCta.label" label="دکمه دوم — متن" required>
<a-input v-model:value="data.secondaryCta.label" />
</FormField>
<FormField path="secondaryCta.href" label="دکمه دوم — لینک" required>
<a-input v-model:value="data.secondaryCta.href" class="ltr-input" />
</FormField>
</div>
</a-card>
<a-card title="نشان‌های گواهی" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.chips" :create="() => ({ icon: 'ShieldCheck', label: '' })" :max="6" add-label="افزودن نشان" :item-title="(c) => c.label">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`chips.${index}.label`" label="متن" required><a-input v-model:value="item.label" /></FormField>
<FormField :path="`chips.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
</div>
</template>
</ListEditor>
</a-card>
<a-card title="آمار پایین Hero" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.miniStats" :create="() => ({ value: '', label: '' })" :max="4" add-label="افزودن آمار" :item-title="(s) => s.value">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-3">
<FormField :path="`miniStats.${index}.value`" label="عدد" required><a-input v-model:value="item.value" placeholder="۱۸+" /></FormField>
<FormField :path="`miniStats.${index}.label`" label="توضیح" required class="md:col-span-2"><a-input v-model:value="item.label" /></FormField>
</div>
</template>
</ListEditor>
</a-card>
</div>
<div class="space-y-6">
<a-card title="تصویر پس‌زمینه">
<ImageRefField v-model="data.image" path="image" label="تصویر" />
</a-card>
<a-card title="کارت وضعیت">
<FormField path="lastInspection" label="تاریخ آخرین بازرسی" hint="خالی = نمایش داده نمی‌شود">
<a-input v-model:value="data.lastInspection" placeholder="۱۴۰۴/۰۸/۱۲" />
</FormField>
</a-card>
</div>
</div>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import ImageRefField from "@/components/ImageRefField.vue";
import ListEditor from "@/components/ListEditor.vue";
import SectionHeadFields from "@/components/SectionHeadFields.vue";
const data = defineModel<SectionData<"industries">>({ required: true });
</script>
<template>
<div class="space-y-6">
<a-card title="عنوان بخش">
<SectionHeadFields v-model="data.head" />
</a-card>
<a-card title="صنایع" :body-style="{ background: '#fafafa' }">
<ListEditor
v-model="data.items"
:create="() => ({ name: '', nameEn: '', count: '', image: { url: '', alt: '' } })"
:max="12"
add-label="افزودن صنعت"
:item-title="(s) => s.name"
>
<template #default="{ item, index }">
<div class="grid gap-x-6 lg:grid-cols-2">
<div>
<FormField :path="`items.${index}.name`" label="نام" required><a-input v-model:value="item.name" /></FormField>
<FormField :path="`items.${index}.nameEn`" label="نام انگلیسی"><a-input v-model:value="item.nameEn" class="ltr-input" /></FormField>
<FormField :path="`items.${index}.count`" label="تعداد پروژه" hint="مثلاً ۱۴۰+ — خالی = نمایش داده نمی‌شود"><a-input v-model:value="item.count" /></FormField>
</div>
<div>
<ImageRefField v-model="item.image" :path="`items.${index}.image`" label="تصویر" />
</div>
</div>
</template>
</ListEditor>
</a-card>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import IconSelect from "@/components/IconSelect.vue";
import ListEditor from "@/components/ListEditor.vue";
import SectionHeadFields from "@/components/SectionHeadFields.vue";
const data = defineModel<SectionData<"productsIntro">>({ required: true });
</script>
<template>
<div class="space-y-6">
<a-card title="عنوان بخش">
<SectionHeadFields v-model="data.head" />
<FormField path="ctaLabel" label="متن دکمه کاتالوگ" required>
<a-input v-model:value="data.ctaLabel" />
</FormField>
<p class="text-sm text-gray-500">کارت‌های دسته‌بندی از بخش «دسته‌بندی‌ها» مدیریت می‌شوند.</p>
</a-card>
<a-card title="کارت‌های اعتماد" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.trust" :create="() => ({ icon: 'BadgeCheck', title: '', desc: '' })" :max="6" add-label="افزودن کارت" :item-title="(t) => t.title">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-3">
<FormField :path="`trust.${index}.title`" label="عنوان" required><a-input v-model:value="item.title" /></FormField>
<FormField :path="`trust.${index}.desc`" label="توضیح"><a-input v-model:value="item.desc" /></FormField>
<FormField :path="`trust.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
</div>
</template>
</ListEditor>
</a-card>
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import IconSelect from "@/components/IconSelect.vue";
import ListEditor from "@/components/ListEditor.vue";
import SectionHeadFields from "@/components/SectionHeadFields.vue";
type Service = SectionData<"services">["items"][number];
const data = defineModel<SectionData<"services">>({ required: true });
const newService = (): Service => ({ icon: "ClipboardCheck", title: "", desc: "", items: [] });
</script>
<template>
<div class="space-y-6">
<a-card title="عنوان بخش">
<SectionHeadFields v-model="data.head" />
</a-card>
<a-card title="خدمات" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.items" :create="newService" :max="10" add-label="افزودن خدمت" :item-title="(s) => s.title">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`items.${index}.title`" label="عنوان" required><a-input v-model:value="item.title" /></FormField>
<FormField :path="`items.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
<FormField :path="`items.${index}.desc`" label="توضیح" required class="md:col-span-2">
<a-textarea v-model:value="item.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
<FormField :path="`items.${index}.items`" label="موارد (فهرست)" hint="هر مورد را بنویسید و Enter بزنید" class="md:col-span-2">
<a-select v-model:value="item.items" mode="tags" :open="false" />
</FormField>
</div>
</template>
</ListEditor>
</a-card>
</div>
</template>

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