Author SHA1 Message Date
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
407 changed files with 22452 additions and 11916 deletions
+14 -7
View File
@@ -1,9 +1,16 @@
node_modules
.nuxt
.output
**/node_modules
**/dist
**/.output
**/.nuxt
**/.turbo
**/*.tsbuildinfo
**/.env
**/.env.*
!**/.env.example
apps/api/src/generated
.git
*.md
out
backups
محصولات
_dev.log
.env*
npm-debug.log*
logo.png
*.skill
+34
View File
@@ -0,0 +1,34 @@
# 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=مدیر سایت
# npm registry used while building the images (set a mirror if npmjs.org is slow or blocked)
NPM_REGISTRY=https://registry.npmjs.org/
+12 -3
View File
@@ -1,12 +1,16 @@
# dependencies
node_modules/
# nuxt
.nuxt/
# build output
dist/
.output/
.nuxt/
.data/
dist
.turbo/
*.tsbuildinfo
# prisma generated client
apps/api/src/generated/
# misc
.DS_Store
@@ -14,7 +18,12 @@ dist
# debug
npm-debug.log*
pnpm-debug.log*
# local env files
.env
.env*.local
!.env.example
# database dumps from the backup service
backups/
+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;"]
+152 -146
View File
@@ -1,149 +1,155 @@
# آریا ایمن — لندینگ (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` |
| سلسله‌مراتب H2/H3 منطقی، بدون پرش سطح | ✅ | `app/components/ui/SectionHead.vue` و همه سکشن‌ها |
| عنوان، title template و توضیحات | ✅ | `shared/config/site.ts`, `app/composables/useSiteSeo.ts` |
| Canonical یکسان با sitemap (`https://ariaiman.ir/`) | ✅ | `app/pages/index.vue` |
| Open Graph و Twitter Card **همراه با تصویر** (og:image/twitter:image) | ✅ | `app/composables/useSiteSeo.ts` |
| `lang="fa"` و `dir="rtl"` | ✅ | `nuxt.config.ts` |
| JSON-LD: LocalBusiness (+ ساعات کاری، سال تأسیس، منطقه خدمات)، WebSite، Service ×۴ | ✅ | `shared/seo/schema.ts`, `app/composables/useJsonLd.ts` |
| `sitemap.xml` / `robots.txt` / `manifest.webmanifest` (با آیکون) | ✅ | `server/routes/` |
| صفحه ۴۰۴ واقعی پیش‌رندرشده، `noindex` و بدون canonical | ✅ | `app/error.vue`, `nuxt.config.ts` |
| preload تصویر hero و فونت‌ها | ✅ | `app/pages/index.vue`, `nuxt.config.ts` |
| alt توصیفی روی همه تصاویر محتوایی | ✅ | همه سکشن‌ها |
| تمام متن در HTML بدون نیاز به اجرای JS | ✅ | خروجی `out/index.html` |
| فونت و تصاویر خودمیزبان (بدون CDN) | ✅ | `public/` |
| سایت | `apps/web` | Nuxt 4 (SSR + کش SWR)، Vue 3، Tailwind v4، Ant Design Vue، Pinia |
| API | `apps/api` | NestJS 12، Prisma 7، PostgreSQL، ذخیره‌ساز S3 (MinIO) |
| پنل مدیریت | `apps/admin` | Vue 3 + Vite، Ant Design Vue، Tailwind v4، Pinia، Vue Router |
| کد مشترک | `packages/shared` | اسکیماهای Zod و تایپ‌ها (مشترک بین هر سه اپ) |
ابزار مونوریپو: **pnpm workspaces + Turborepo**.
```
مرورگر ──► gateway (nginx)
├─ imenpishgaman.ir → web (Nuxt SSR، صفحات کش‌شده)
├─ admin.imenpishgaman.ir → admin (فایل‌های استاتیک)
├─ /api/* → api (NestJS) ──► PostgreSQL
└─ /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های جدید را اعمال می‌کند و خارج می‌شود. Prisma CLI فقط در همین image هست و image اجرایی API سبک می‌ماند.
- نام پروژه‌ی 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`) هم جداگانه بکاپ بگیرید.
**اگر سرور به رجیستری npm دسترسی ندارد**، دو راه هست:
1. در `.env` مقدار `NPM_REGISTRY` را روی یک mirror در دسترس بگذارید.
2. imageها را روی سیستم توسعه بسازید و به سرور منتقل کنید:
```bash
docker compose build
docker save imen-api imen-migrate imen-web imen-admin | gzip > imen-images.tar.gz
# روی سرور:
gunzip -c imen-images.tar.gz | docker load && docker compose up -d
```
**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>
-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>
-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";
},
},
});
+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 "خطای ناشناخته";
}
+77
View File
@@ -0,0 +1,77 @@
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;
}
+49
View File
@@ -0,0 +1,49 @@
<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;
}
</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>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { ICON_NAMES, type IconName } from "@imen/shared";
import { computed } from "vue";
import { ICONS } from "./icons";
/** Picks one of the Lucide icons the website knows how to render */
const value = defineModel<string>({ required: true });
defineProps<{ allowClear?: boolean }>();
const options = ICON_NAMES.map((name) => ({ value: name, label: name }));
const current = computed(() => ICONS[value.value as IconName]);
</script>
<template>
<div class="flex items-center gap-2">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-gray-200 text-[#ff5a00]">
<component :is="current" v-if="current" class="size-4" />
</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" dir="ltr">
<component :is="ICONS[name as IconName]" class="size-4" />
{{ name }}
</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>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
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="بازگشت">→</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 {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.page-header__back {
color: #94a3b8;
text-decoration: none;
transition: color 0.15s ease;
}
.page-header__back:hover {
color: #334155;
}
.page-header__title {
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
}
.page-header__subtitle {
margin: 2px 0 0;
font-size: 13px;
color: #94a3b8;
}
.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>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from "vue";
import icons from "@/icons/vuesax-linear.json";
const props = withDefaults(
defineProps<{
/** Short name (`add`) or full key (`vuesax-linear:add`) */
name: string;
size?: number | string;
}>(),
{ size: 18 },
);
const pack = icons as Record<string, string>;
const svgHtml = computed(() => {
const key = props.name.startsWith("vuesax-linear:")
? props.name
: `vuesax-linear:${props.name}`;
const raw = pack[key];
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="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;
}
.vx-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
import type { IconName } from "@imen/shared";
import {
Award,
BadgeCheck,
Building2,
ClipboardCheck,
Clock,
Construction,
Ear,
Factory,
FileCheck,
FileSearch,
Flame,
Footprints,
Fuel,
Gauge,
Glasses,
Globe,
GraduationCap,
Hand,
Handshake,
HardHat,
Headset,
HeartPulse,
Leaf,
Lock,
Mail,
MapPin,
Medal,
Microscope,
Package,
PackageCheck,
Phone,
Pickaxe,
ShieldAlert,
ShieldCheck,
ShieldPlus,
Shirt,
Siren,
Star,
Target,
ThumbsUp,
TrafficCone,
TriangleAlert,
Truck,
Users,
Warehouse,
Wind,
Wrench,
Zap,
} from "lucide-vue-next";
import type { Component } from "vue";
/** Same icon set as the website's DynamicIcon (ICON_NAMES in @imen/shared) */
export const ICONS: Record<IconName, Component> = {
ShieldCheck,
ShieldPlus,
ShieldAlert,
BadgeCheck,
Award,
Medal,
PackageCheck,
Package,
Truck,
Warehouse,
Globe,
Users,
Handshake,
Headset,
ClipboardCheck,
FileCheck,
FileSearch,
GraduationCap,
Gauge,
Microscope,
Wrench,
HardHat,
Shirt,
Footprints,
Hand,
Glasses,
Ear,
Wind,
TriangleAlert,
Siren,
TrafficCone,
Construction,
Factory,
Building2,
Fuel,
Pickaxe,
Flame,
Zap,
Leaf,
HeartPulse,
Clock,
Phone,
Mail,
MapPin,
Star,
ThumbsUp,
Target,
Lock,
};
+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
+581
View File
@@ -0,0 +1,581 @@
<script setup lang="ts">
import { ROLE_LABELS } from "@imen/shared";
import { computed, h, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useAuthStore } from "@/stores/auth";
import { SITE_URL } from "@/utils/format";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const drawer = ref(false);
/** Icon badge box + Vuesax glyph (keeps pre-swap menu structure) */
const menuIcon = (name: string) => () =>
h("span", { class: "admin-menu__icon" }, [h(VuesaxIcon, { name, size: 16 })]);
const items = computed(() => [
{ key: "dashboard", icon: menuIcon("element-3"), label: "داشبورد" },
{ key: "products", icon: menuIcon("bag-2"), label: "محصولات" },
{ key: "categories", icon: menuIcon("category"), label: "دسته‌بندی‌ها" },
{ key: "sections", icon: menuIcon("row-vertical"), label: "بخش‌های سایت" },
{ key: "messages", icon: menuIcon("sms"), label: "پیام‌های تماس" },
{ key: "media", icon: menuIcon("gallery"), label: "کتابخانه رسانه" },
...(auth.isAdmin ? [{ key: "users", icon: menuIcon("profile-2user"), label: "کاربران" }] : []),
]);
/** menu entry to highlight for nested pages (products/12 → products) */
const MENU_OF: Record<string, string> = {
"product-new": "products",
"product-edit": "products",
"category-new": "categories",
"category-edit": "categories",
"section-edit": "sections",
};
const PAGE_TITLES: Record<string, string> = {
dashboard: "داشبورد",
products: "محصولات",
"product-new": "محصول جدید",
"product-edit": "ویرایش محصول",
categories: "دسته‌بندی‌ها",
"category-new": "دسته‌بندی جدید",
"category-edit": "ویرایش دسته‌بندی",
sections: "بخش‌های سایت",
"section-edit": "ویرایش بخش",
messages: "پیام‌های تماس",
media: "کتابخانه رسانه",
users: "کاربران",
profile: "حساب کاربری",
};
const selected = computed(() => {
const name = String(route.name ?? "");
return [MENU_OF[name] ?? name];
});
const pageTitle = computed(() => PAGE_TITLES[String(route.name ?? "")] ?? "پنل مدیریت");
const userInitial = computed(() => (auth.user?.name?.trim().charAt(0) || "ک").slice(0, 1));
const go = ({ key }: { key: string | number }) => {
drawer.value = false;
void router.push({ name: String(key) });
};
watch(() => route.fullPath, () => (drawer.value = false));
</script>
<template>
<a-layout class="admin-shell">
<a-layout-sider
:width="248"
breakpoint="lg"
collapsed-width="0"
:trigger="null"
class="admin-sider !hidden lg:!block"
>
<div class="admin-sider__brand">
<span class="admin-sider__logo">
<img src="/logo.png" alt="" class="size-7 object-contain" />
</span>
<div class="leading-tight">
<div class="text-[14px] font-bold text-[#0f172a]">ایمن پیشگامان</div>
<div class="text-[10px] font-medium tracking-wide text-[#94a3b8]">پنل مدیریت</div>
</div>
</div>
<div class="admin-sider__nav">
<div class="admin-sider__label">منوی اصلی</div>
<a-menu
class="admin-menu"
mode="inline"
:selected-keys="selected"
:items="items"
@click="go"
/>
</div>
</a-layout-sider>
<a-drawer
v-model:open="drawer"
placement="right"
:width="280"
:closable="false"
class="admin-drawer"
:body-style="{ padding: 0, background: '#ffffff' }"
>
<div class="admin-sider__brand">
<span class="admin-sider__logo">
<img src="/logo.png" alt="" class="size-7 object-contain" />
</span>
<div class="leading-tight">
<div class="text-[14px] font-bold text-[#0f172a]">ایمن پیشگامان</div>
<div class="text-[10px] font-medium text-[#94a3b8]">پنل مدیریت</div>
</div>
</div>
<div class="admin-sider__nav">
<div class="admin-sider__label">منوی اصلی</div>
<a-menu
class="admin-menu"
mode="inline"
:selected-keys="selected"
:items="items"
@click="go"
/>
</div>
</a-drawer>
<a-layout>
<a-layout-header class="admin-header">
<div class="admin-header__start">
<a-button class="admin-header__menu-btn lg:!hidden" @click="drawer = true">
<template #icon><VuesaxIcon name="menu" :size="18" /></template>
</a-button>
<div class="admin-header__crumb">
<span class="admin-header__crumb-kicker">پنل مدیریت</span>
<span class="admin-header__crumb-sep" aria-hidden="true">/</span>
<span class="admin-header__crumb-page">{{ pageTitle }}</span>
</div>
</div>
<div class="admin-header__end">
<a
:href="SITE_URL"
target="_blank"
rel="noopener"
class="admin-header__site"
>
<VuesaxIcon name="export" :size="16" />
<span>مشاهده سایت</span>
</a>
<div class="admin-header__divider" aria-hidden="true" />
<a-dropdown placement="bottomLeft" :trigger="['click']">
<button type="button" class="admin-header__user">
<span class="admin-header__avatar">{{ userInitial }}</span>
<span class="admin-header__user-meta">
<span class="admin-header__user-name">{{ auth.user?.name }}</span>
<span v-if="auth.user" class="admin-header__user-role">
{{ ROLE_LABELS[auth.user.role] }}
</span>
</span>
<VuesaxIcon name="arrow-down" :size="12" class="admin-header__chevron" />
</button>
<template #overlay>
<a-menu class="admin-header__dropdown">
<a-menu-item key="profile" @click="router.push({ name: 'profile' })">
<VuesaxIcon name="user" :size="16" /> حساب کاربری
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" danger @click="auth.logout()">
<VuesaxIcon name="logout" :size="16" /> خروج
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<a-layout-content class="admin-content">
<div class="admin-page">
<RouterView v-slot="{ Component, route: r }">
<component :is="Component" :key="r.path" />
</RouterView>
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
.admin-shell {
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.admin-shell > :deep(.ant-layout) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.admin-header {
position: sticky;
top: 0;
z-index: 20;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 64px !important;
line-height: 1.3 !important;
padding-inline: 16px 20px !important;
background: rgba(255, 255, 255, 0.96) !important;
border-bottom: 1px solid #e8ecf2;
backdrop-filter: blur(10px);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
}
.admin-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 16px;
background: #f5f6fa;
box-sizing: border-box;
}
/* White panel locked to remaining viewport; pages manage their own inner scroll */
.admin-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: #ffffff;
border: 1px solid #e8ecf2;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.admin-page > :deep(*) {
flex: 1;
min-width: 0;
min-height: 0;
}
/* Pages that don't use AdminPage still scroll inside the panel */
.admin-page > :deep(*:not(.ap)) {
overflow: auto;
padding: 16px;
box-sizing: border-box;
}
@media (min-width: 768px) {
.admin-header {
padding-inline: 24px !important;
}
}
.admin-header__start,
.admin-header__end {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.admin-header__menu-btn {
border-radius: 10px !important;
border-color: #e2e8f0 !important;
color: #475569 !important;
}
.admin-header__crumb {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-header__crumb-kicker {
display: none;
font-size: 12px;
font-weight: 500;
color: #94a3b8;
}
@media (min-width: 640px) {
.admin-header__crumb-kicker {
display: inline;
}
}
.admin-header__crumb-sep {
display: none;
color: #cbd5e1;
}
@media (min-width: 640px) {
.admin-header__crumb-sep {
display: inline;
}
}
.admin-header__crumb-page {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.admin-header__site {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding-inline: 14px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #ffffff;
color: #475569;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition:
color 0.18s ease,
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
}
.admin-header__site:hover {
color: #e04f00;
border-color: #ffd0b5;
background: #fff7f2;
box-shadow: 0 2px 8px rgba(255, 90, 0, 0.08);
}
.admin-header__divider {
width: 1px;
height: 28px;
background: #e8ecf2;
}
.admin-header__user {
display: inline-flex;
align-items: center;
gap: 10px;
max-width: 260px;
height: 44px;
padding-block: 4px;
padding-inline: 4px 10px;
border: 1px solid #e8ecf2;
border-radius: 999px;
background: #f8fafc;
color: inherit;
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
}
.admin-header__user:hover {
background: #ffffff;
border-color: #ffd0b5;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
}
.admin-header__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 999px;
background: linear-gradient(135deg, #ff5a00, #ff8a3d);
color: #ffffff;
font-size: 14px;
font-weight: 700;
box-shadow: 0 2px 8px rgba(255, 90, 0, 0.28);
}
.admin-header__user-meta {
display: none;
flex-direction: column;
align-items: flex-start;
min-width: 0;
line-height: 1.25;
}
@media (min-width: 640px) {
.admin-header__user-meta {
display: flex;
}
}
.admin-header__user-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.admin-header__user-role {
font-size: 11px;
font-weight: 500;
color: #94a3b8;
}
.admin-header__chevron {
font-size: 10px;
color: #94a3b8;
}
.admin-sider {
height: 100vh !important;
height: 100dvh !important;
max-height: 100vh !important;
max-height: 100dvh !important;
position: sticky !important;
top: 0;
background: #ffffff !important;
/* content-facing edge (left in RTL) — continuous vertical rail */
border-inline-end: 1px solid #e8ecf2;
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.04);
}
.admin-sider :deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.admin-sider__brand {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 10px;
height: 64px;
padding: 0 16px;
border-bottom: 1px solid #eef1f6;
box-sizing: border-box;
}
.admin-sider__logo {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #fff7f2;
border: 1px solid #ffe0cc;
box-shadow: 0 1px 2px rgba(255, 90, 0, 0.06);
}
.admin-sider__nav {
flex: 1;
min-height: 0;
height: calc(100vh - 64px);
height: calc(100dvh - 64px);
padding: 16px 12px 20px;
overflow-x: hidden;
overflow-y: auto;
}
.admin-sider__label {
margin: 0 8px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: #94a3b8;
}
.admin-menu {
background: transparent !important;
border: 0 !important;
}
.admin-menu :deep(.ant-menu-item) {
display: flex;
align-items: center;
height: 44px !important;
margin: 0 0 6px !important;
padding-inline: 12px !important;
border: 1px solid transparent !important;
border-radius: 12px !important;
color: #475569 !important;
font-weight: 500;
line-height: 44px !important;
transition:
background 0.2s ease,
color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.admin-menu :deep(.ant-menu-item-icon),
.admin-menu :deep(.ant-menu-item .anticon) {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: auto !important;
height: auto !important;
margin-inline-end: 0 !important;
font-size: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.admin-menu :deep(.admin-menu__icon) {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-inline-end: 10px;
border-radius: 9px;
background: #f1f5f9;
color: #64748b;
transition:
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
}
.admin-menu :deep(.admin-menu__icon .vx-icon) {
width: 16px !important;
height: 16px !important;
background: transparent !important;
box-shadow: none !important;
color: inherit;
}
.admin-menu :deep(.ant-menu-item:hover) {
background: #f8fafc !important;
color: #0f172a !important;
border-color: #eef1f6 !important;
}
.admin-menu :deep(.ant-menu-item:hover .admin-menu__icon) {
background: #e8eef7;
color: #475569;
}
.admin-menu :deep(.ant-menu-item-selected) {
background: #fff7f2 !important;
border-color: #ffd7bf !important;
color: #c2410c !important;
font-weight: 700;
box-shadow: 0 4px 14px rgba(255, 90, 0, 0.1);
}
.admin-menu :deep(.ant-menu-item-selected .admin-menu__icon) {
background: #ff5a00 !important;
color: #ffffff !important;
box-shadow: 0 4px 10px rgba(255, 90, 0, 0.32);
}
.admin-menu :deep(.ant-menu-item-selected::after) {
display: none !important;
}
</style>
+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");
+125
View File
@@ -0,0 +1,125 @@
<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 { faNumber, SITE_URL } from "@/utils/format";
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 :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
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>
</template>
+203
View File
@@ -0,0 +1,203 @@
<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;
};
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>
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="save">
<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="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' }">
<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="انتشار">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "مخفی" }}</span>
</FormField>
</a-card>
<a-card title="تصویر کاور">
<FormField path="coverImageId">
<ImageField :url="cover?.url" clearable @pick="pickCover" @clear="cover = null; model.coverImageId = null" />
</FormField>
</a-card>
<a-card title="آیکون">
<FormField path="icon">
<IconSelect v-model="model.icon" allow-clear />
</FormField>
</a-card>
<a-card title="سئو">
<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>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { type DashboardDto, MESSAGE_STATUS_LABELS } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { activityLabel, faNumber, jalali } from "@/utils/format";
const data = ref<DashboardDto | null>(null);
onMounted(async () => {
try {
data.value = await api<DashboardDto>("/admin/dashboard");
} catch (e) {
void message.error(errorMessage(e));
}
});
</script>
<template>
<PageHeader title="داشبورد" subtitle="خلاصه وضعیت محتوای سایت">
<RouterLink :to="{ name: 'product-new' }">
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</PageHeader>
<a-spin :spinning="!data">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
<RouterLink :to="{ name: 'products' }">
<a-card hoverable><a-statistic title="محصولات" :value="faNumber(data?.products ?? 0)" /></a-card>
</RouterLink>
<RouterLink :to="{ name: 'products', query: { status: 'draft' } }">
<a-card hoverable><a-statistic title="پیش‌نویس" :value="faNumber(data?.draftProducts ?? 0)" /></a-card>
</RouterLink>
<RouterLink :to="{ name: 'categories' }">
<a-card hoverable><a-statistic title="دسته‌بندی‌ها" :value="faNumber(data?.categories ?? 0)" /></a-card>
</RouterLink>
<RouterLink :to="{ name: 'messages', query: { status: 'NEW' } }">
<a-card hoverable>
<a-statistic
title="پیام‌های جدید"
:value="faNumber(data?.newMessages ?? 0)"
:value-style="data?.newMessages ? { color: '#ff5a00' } : undefined"
/>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'media' }">
<a-card hoverable><a-statistic title="تصاویر" :value="faNumber(data?.media ?? 0)" /></a-card>
</RouterLink>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<a-card title="آخرین پیام‌های تماس">
<template #extra><RouterLink :to="{ name: 'messages' }">همه پیام‌ها</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' : 'default'">{{ MESSAGE_STATUS_LABELS[item.status as keyof typeof MESSAGE_STATUS_LABELS] }}</a-tag>
<div class="mt-1">{{ jalali(item.createdAt) }}</div>
</div>
</a-list-item>
</template>
</a-list>
</a-card>
<a-card title="آخرین فعالیت‌ها">
<a-empty v-if="!data?.recentActivity.length" description="فعالیتی ثبت نشده است" />
<a-timeline v-else class="!mt-2">
<a-timeline-item v-for="a in data.recentActivity" :key="a.id">
<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="text-xs text-gray-400">{{ a.user ?? "سیستم" }} · {{ jalali(a.createdAt) }}</div>
</a-timeline-item>
</a-timeline>
</a-card>
</div>
</a-spin>
</template>
+208
View File
@@ -0,0 +1,208 @@
<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-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: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) {
color: #8e9ab0 !important;
}
.login-panel :deep(.ant-form-item-explain-error) {
color: #ff7a2a;
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { faNumber, fileSize, jalali } from "@/utils/format";
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;
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(load);
</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 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-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-9">
<button
v-for="m in items"
:key="m.id"
type="button"
class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50 transition hover:border-[#ff5a00]"
:title="m.originalName"
@click="open(m)"
>
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain" loading="lazy" />
</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-card>
<a-drawer :open="!!selected" title="جزئیات تصویر" :width="380" placement="left" @close="selected = null">
<template v-if="selected">
<img :src="selected.url" :alt="selected.alt" class="w-full rounded-lg border border-gray-200 bg-gray-50 object-contain" />
<a-descriptions :column="1" size="small" class="mt-4">
<a-descriptions-item label="نام فایل"><span dir="ltr">{{ selected.originalName }}</span></a-descriptions-item>
<a-descriptions-item label="ابعاد">
<span dir="ltr">{{ selected.width }}×{{ selected.height }}</span>
</a-descriptions-item>
<a-descriptions-item label="حجم">{{ fileSize(selected.size) }}</a-descriptions-item>
<a-descriptions-item label="تاریخ">{{ jalali(selected.createdAt) }}</a-descriptions-item>
</a-descriptions>
<a-form layout="vertical" class="mt-4">
<a-form-item label="متن جایگزین (alt)">
<a-input v-model:value="altDraft" />
</a-form-item>
<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 title="این تصویر حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove">
<a-button danger><template #icon><VuesaxIcon name="trash" :size="16" /></template>حذف</a-button>
</a-popconfirm>
</a-space>
</a-form>
</template>
</a-drawer>
</template>
+126
View File
@@ -0,0 +1,126 @@
<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 { 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 { faNumber, jalali } from "@/utils/format";
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 PAGE_SIZE = 20;
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;
} catch (e) {
void message.error(errorMessage(e));
}
}
/** 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));
}
}
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)} پیام — درخواست‌های ثبت‌شده از فرم مشاوره سایت`">
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
</PageHeader>
<a-card :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">{{ 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 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>
</template>
+346
View File
@@ -0,0 +1,346 @@
<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 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">
<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>
</PageHeader>
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="save">
<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="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>
<a-card title="توضیحات کامل (صفحه محصول)">
<FormField path="details" hint="برای شروع پاراگراف جدید یک خط خالی بگذارید. اگر خالی بماند، توضیح دسته نمایش داده می‌شود.">
<a-textarea
v-model:value="model.details"
:auto-size="{ minRows: 6, maxRows: 20 }"
placeholder="معرفی محصول، جنس و ساختار، مزایا، نکات نگهداری و ..."
/>
</FormField>
</a-card>
<a-card title="ویژگی‌ها و کاربردها">
<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>
<a-card title="مشخصات فنی" :body-style="{ background: '#fafafa' }">
<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>
<a-card title="گالری تصاویر">
<p class="mb-4 text-sm text-gray-500">تصاویر بیشتر محصول؛ در صفحه محصول بعد از تصویر اصلی نمایش داده می‌شوند.</p>
<div class="flex flex-wrap gap-3">
<div v-for="m in gallery" :key="m.id" class="relative size-24 overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
<img :src="m.url" alt="" class="size-full object-contain" />
<button
type="button"
class="absolute end-1 top-1 flex size-6 items-center justify-center rounded-full bg-white/90 text-red-500 shadow"
title="حذف از گالری"
@click="removeFromGallery(m.id)"
>
<VuesaxIcon name="close-circle" :size="16" class="text-xs" />
</button>
</div>
<button
type="button"
class="flex size-24 items-center justify-center rounded-lg border border-dashed border-gray-300 text-gray-400 hover:border-[#ff5a00] hover:text-[#ff5a00]"
@click="galleryPicker = true"
>
<VuesaxIcon name="add" :size="16" />
</button>
</div>
<MediaPicker v-model:open="galleryPicker" @select="addToGallery" />
</a-card>
</div>
<div class="space-y-6">
<a-card title="انتشار">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "پیش‌نویس (مخفی)" }}</span>
</FormField>
</a-card>
<a-card title="دسته‌بندی">
<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>
<a-card title="تصویر اصلی">
<FormField path="imageId" hint="بدون تصویر، کاور دسته نمایش داده می‌شود">
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
</FormField>
</a-card>
<a-card title="برند و سفارش">
<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>
<a-card title="سئو">
<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>
</template>
+452
View File
@@ -0,0 +1,452 @@
<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";
const route = useRoute();
const router = useRouter();
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 activeFilterCount = computed(
() => [filters.categoryId, filters.groupId, filters.status].filter(Boolean).length,
);
/** 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>
<div class="toolbar">
<div class="toolbar__start">
<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>
<a-input-search
v-model:value="filters.q"
allow-clear
class="toolbar__search"
placeholder="جستجوی نام یا کد"
@search="
() => {
filters.page = 1;
load();
}
"
/>
</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>
</template>
<div ref="tableShell" class="table-shell">
<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
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>
</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>
+56
View File
@@ -0,0 +1,56 @@
<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="حساب کاربری" />
<div class="grid max-w-4xl gap-6 md:grid-cols-2">
<a-card title="مشخصات">
<a-descriptions :column="1">
<a-descriptions-item label="نام">{{ auth.user?.name }}</a-descriptions-item>
<a-descriptions-item label="موبایل"><span dir="ltr">{{ auth.user?.phone }}</span></a-descriptions-item>
<a-descriptions-item label="نقش">{{ auth.user && ROLE_LABELS[auth.user.role] }}</a-descriptions-item>
</a-descriptions>
</a-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" autocomplete="current-password" />
</FormField>
<FormField path="newPassword" label="رمز جدید" hint="حداقل ۸ کاراکتر" required>
<a-input-password v-model:value="model.newPassword" class="ltr-input" autocomplete="new-password" />
</FormField>
<a-button type="primary" html-type="submit" :loading="saving">تغییر رمز</a-button>
</a-form>
</a-card>
</div>
</template>
+105
View File
@@ -0,0 +1,105 @@
<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 { 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 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}`);
reset(res.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;
}
}
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"
>
<a :href="SITE_URL" target="_blank" rel="noopener"><a-button>مشاهده سایت</a-button></a>
<a-button type="primary" :loading="saving" :disabled="!loaded" @click="save">
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</a-button>
</PageHeader>
<a-spin :spinning="!loaded">
<a-form v-if="loaded" layout="vertical" @submit.prevent="save">
<component :is="FORMS[sectionKey]" v-model="model" />
</a-form>
</a-spin>
</template>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { SECTION_KEYS, SECTION_LABELS } from "@imen/shared";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
// site-wide settings are editable by admins only
const keys = SECTION_KEYS.filter((k) => k !== "settings" || auth.isAdmin);
</script>
<template>
<PageHeader
title="بخش‌های سایت"
subtitle="متن‌ها، تصاویر و فهرست‌های صفحه اصلی؛ تغییرات پس از ذخیره حداکثر ظرف یک دقیقه روی سایت دیده می‌شوند"
/>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<RouterLink v-for="key in keys" :key="key" :to="{ name: 'section-edit', params: { key } }">
<a-card hoverable class="h-full">
<div class="font-bold text-gray-800">{{ SECTION_LABELS[key].title }}</div>
<p class="mt-2 text-sm leading-6 text-gray-500">{{ SECTION_LABELS[key].desc }}</p>
</a-card>
</RouterLink>
</div>
</template>
+180
View File
@@ -0,0 +1,180 @@
<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>
<a-card :body-style="{ padding: 0 }">
<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>
<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"
/>
</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>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { type MediaDto, type SectionData, SOCIAL_TYPES, type SocialType, WEEK_DAYS } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import ImageField from "@/components/ImageField.vue";
import ListEditor from "@/components/ListEditor.vue";
const data = defineModel<SectionData<"settings">>({ required: true });
const DAY_LABELS: Record<(typeof WEEK_DAYS)[number], string> = {
Saturday: "شنبه",
Sunday: "یکشنبه",
Monday: "دوشنبه",
Tuesday: "سه‌شنبه",
Wednesday: "چهارشنبه",
Thursday: "پنجشنبه",
Friday: "جمعه",
};
const SOCIAL_LABELS: Record<SocialType, string> = {
website: "وب‌سایت",
telegram: "تلگرام",
instagram: "اینستاگرام",
linkedin: "لینکدین",
whatsapp: "واتساپ",
bale: "بله",
eitaa: "ایتا",
aparat: "آپارات",
youtube: "یوتیوب",
email: "ایمیل (mailto:)",
phone: "تلفن (tel:)",
};
type Social = SectionData<"settings">["socials"][number];
const URL_PLACEHOLDER = "https://t.me/...";
const newSocial = (): Social => ({ type: "telegram", url: "" });
const socialTitle = (s: Social) => SOCIAL_LABELS[s.type];
const SOCIAL_OPTIONS = SOCIAL_TYPES.map((t) => ({ value: t, label: SOCIAL_LABELS[t] }));
const DAY_OPTIONS = WEEK_DAYS.map((d) => ({ value: d, label: DAY_LABELS[d] }));
function pickOgImage(m: MediaDto) {
data.value.ogImage = { url: m.url, alt: data.value.ogImage.alt || m.alt, width: m.width, height: m.height };
}
</script>
<template>
<div class="grid gap-6 xl:grid-cols-2">
<a-card title="هویت و سئوی پیش‌فرض">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام شرکت" required><a-input v-model:value="data.name" /></FormField>
<FormField path="nameEn" label="نام انگلیسی" required><a-input v-model:value="data.nameEn" class="ltr-input" /></FormField>
<FormField path="title" label="عنوان پیش‌فرض صفحات (title)" required class="md:col-span-2">
<a-input v-model:value="data.title" :maxlength="120" show-count />
</FormField>
<FormField path="description" label="توضیحات متا (description)" required class="md:col-span-2">
<a-textarea v-model:value="data.description" :maxlength="400" show-count :auto-size="{ minRows: 3 }" />
</FormField>
<FormField path="foundingDate" label="سال تأسیس (میلادی)" required>
<a-input v-model:value="data.foundingDate" class="ltr-input" placeholder="2007" />
</FormField>
</div>
<FormField path="ogImage.url" label="تصویر اشتراک‌گذاری (Open Graph)" hint="ترجیحاً ۱۲۰۰×۶۳۰ پیکسل" required>
<ImageField :url="data.ogImage.url" @pick="pickOgImage" />
</FormField>
<FormField path="ogImage.alt" label="متن جایگزین تصویر">
<a-input v-model:value="data.ogImage.alt" />
</FormField>
</a-card>
<a-card title="اطلاعات تماس">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="phoneDisplay" label="تلفن (نمایشی)" required>
<a-input v-model:value="data.phoneDisplay" />
</FormField>
<FormField path="phone" label="تلفن (برای لینک و گوگل)" hint="با کد کشور، مثلاً ‎+98-21-91008800" required>
<a-input v-model:value="data.phone" class="ltr-input" />
</FormField>
<FormField path="email" label="ایمیل" required class="md:col-span-2">
<a-input v-model:value="data.email" class="ltr-input" />
</FormField>
<FormField path="address.locality" label="شهر" required>
<a-input v-model:value="data.address.locality" />
</FormField>
<FormField path="address.country" label="کد کشور" required>
<a-input v-model:value="data.address.country" class="ltr-input" :maxlength="2" />
</FormField>
<FormField path="address.street" label="نشانی" required class="md:col-span-2">
<a-input v-model:value="data.address.street" />
</FormField>
</div>
</a-card>
<a-card title="ساعات کاری">
<FormField path="workingHours" label="متن نمایشی" required>
<a-input v-model:value="data.workingHours" />
</FormField>
<FormField path="openingHours.days" label="روزهای کاری (برای گوگل)">
<a-checkbox-group
v-model:value="data.openingHours.days"
:options="DAY_OPTIONS"
/>
</FormField>
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="openingHours.opens" label="ساعت شروع" required>
<a-input v-model:value="data.openingHours.opens" class="ltr-input" placeholder="08:00" />
</FormField>
<FormField path="openingHours.closes" label="ساعت پایان" required>
<a-input v-model:value="data.openingHours.closes" class="ltr-input" placeholder="17:00" />
</FormField>
</div>
</a-card>
<a-card title="شبکه‌های اجتماعی و لینک‌ها" :body-style="{ background: '#fafafa' }">
<ListEditor
v-model="data.socials"
:create="newSocial"
:max="12"
add-label="افزودن لینک"
:item-title="socialTitle"
>
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-3">
<FormField :path="`socials.${index}.type`" label="نوع">
<a-select v-model:value="item.type" :options="SOCIAL_OPTIONS" />
</FormField>
<FormField :path="`socials.${index}.url`" label="آدرس" required class="md:col-span-2">
<a-input v-model:value="item.url" class="ltr-input" :placeholder="URL_PLACEHOLDER" />
</FormField>
</div>
</template>
</ListEditor>
</a-card>
</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 ListEditor from "@/components/ListEditor.vue";
import SectionHeadFields from "@/components/SectionHeadFields.vue";
const data = defineModel<SectionData<"standards">>({ required: true });
</script>
<template>
<div class="space-y-6">
<a-card title="عنوان و متن">
<SectionHeadFields v-model="data.head" />
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="note" label="کادر توضیح (اختیاری)">
<a-textarea v-model:value="data.note" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
<FormField path="ctaLabel" label="متن دکمه" required>
<a-input v-model:value="data.ctaLabel" />
</FormField>
</div>
</a-card>
<a-card title="استانداردها" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.items" :create="() => ({ code: '', label: '' })" :max="12" add-label="افزودن استاندارد" :item-title="(s) => s.code">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`items.${index}.code`" label="کد استاندارد" required><a-input v-model:value="item.code" class="ltr-input" placeholder="EN 397" /></FormField>
<FormField :path="`items.${index}.label`" label="عنوان" required><a-input v-model:value="item.label" /></FormField>
</div>
</template>
</ListEditor>
</a-card>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import ListEditor from "@/components/ListEditor.vue";
const data = defineModel<SectionData<"stats">>({ required: true });
</script>
<template>
<a-card title="شمارنده‌ها" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.items" :create="() => ({ end: 0, suffix: '+', label: '', note: '' })" :max="8" add-label="افزودن شمارنده" :item-title="(s) => s.label">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-4">
<FormField :path="`items.${index}.end`" label="عدد نهایی" required>
<a-input-number v-model:value="item.end" :min="0" class="!w-full" />
</FormField>
<FormField :path="`items.${index}.suffix`" label="پسوند" hint="مثلاً + یا ٪">
<a-input v-model:value="item.suffix" />
</FormField>
<FormField :path="`items.${index}.label`" label="عنوان" required>
<a-input v-model:value="item.label" />
</FormField>
<FormField :path="`items.${index}.note`" label="یادداشت انگلیسی کوچک">
<a-input v-model:value="item.note" class="ltr-input" />
</FormField>
</div>
</template>
</ListEditor>
</a-card>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
const data = defineModel<SectionData<"tape">>({ required: true });
</script>
<template>
<a-card title="عبارت‌های نوار متحرک">
<FormField path="items" label="عبارت‌ها" hint="هر عبارت را بنویسید و Enter بزنید؛ برای حذف روی × کلیک کنید" required>
<a-select v-model:value="data.items" mode="tags" :open="false" />
</FormField>
</a-card>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SectionData } from "@imen/shared";
import FormField from "@/components/FormField.vue";
import ListEditor from "@/components/ListEditor.vue";
import SectionHeadFields from "@/components/SectionHeadFields.vue";
const data = defineModel<SectionData<"testimonials">>({ required: true });
</script>
<template>
<div class="space-y-6">
<a-card title="عنوان بخش">
<SectionHeadFields v-model="data.head" :with-highlight="false" :with-desc="false" />
</a-card>
<a-card title="نظرات" :body-style="{ background: '#fafafa' }">
<ListEditor v-model="data.items" :create="() => ({ text: '', name: '', role: '' })" :max="12" add-label="افزودن نظر" :item-title="(q) => q.name">
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`items.${index}.text`" label="متن نظر" required class="md:col-span-2">
<a-textarea v-model:value="item.text" :auto-size="{ minRows: 2, maxRows: 5 }" />
</FormField>
<FormField :path="`items.${index}.name`" label="نام" required><a-input v-model:value="item.name" /></FormField>
<FormField :path="`items.${index}.role`" label="سمت و سازمان"><a-input v-model:value="item.role" /></FormField>
</div>
</template>
</ListEditor>
</a-card>
</div>
</template>
+67
View File
@@ -0,0 +1,67 @@
import type { AdminUserDto, AuthResponse, LoginInput } from "@imen/shared";
import { defineStore } from "pinia";
import { rawApi, setAuthHooks } from "@/api/http";
import { router } from "@/router";
let refreshing: Promise<boolean> | null = null;
export const useAuthStore = defineStore("auth", {
state: () => ({
/** short-lived access token, kept in memory only */
token: "",
user: null as AdminUserDto | null,
/** true once the initial session check has run */
ready: false,
}),
getters: {
isAdmin: (s) => s.user?.role === "ADMIN",
},
actions: {
setSession(r: AuthResponse) {
this.token = r.accessToken;
this.user = r.user;
},
clear() {
this.token = "";
this.user = null;
},
async login(input: LoginInput) {
this.setSession(await rawApi<AuthResponse>("/admin/auth/login", { method: "POST", body: input }));
},
/** Exchanges the refresh cookie for a new access token; concurrent calls share one request */
refresh(): Promise<boolean> {
refreshing ??= rawApi<AuthResponse>("/admin/auth/refresh", { method: "POST" })
.then((r) => {
this.setSession(r);
return true;
})
.catch(() => {
this.clear();
return false;
})
.finally(() => {
refreshing = null;
});
return refreshing;
},
/** Restores the session after a page load (the refresh cookie survives reloads) */
async init() {
if (this.ready) return;
setAuthHooks({
token: () => this.token,
refresh: () => this.refresh(),
onExpired: () => {
this.clear();
void router.push({ name: "login", query: { redirect: router.currentRoute.value.fullPath } });
},
});
await this.refresh();
this.ready = true;
},
async logout() {
await rawApi("/admin/auth/logout", { method: "POST" }).catch(() => undefined);
this.clear();
await router.push({ name: "login" });
},
},
});
+41
View File
@@ -0,0 +1,41 @@
@import "tailwindcss";
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum_Medium.woff2") format("woff2");
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "IRANSans";
src: url("/fonts/IRANSansWebFaNum_Bold.woff2") format("woff2");
font-weight: 700;
font-display: swap;
}
@theme {
--font-sans: "IRANSans", Tahoma, sans-serif;
--color-brand: #ff5a00;
--color-navy: #0b1224;
--color-navy-2: #101a32;
}
html,
body {
background: #f5f6fa;
font-family: var(--font-sans);
}
/* inputs that hold Latin text (codes, slugs, URLs) read left-to-right */
.ltr-input input,
input.ltr-input,
textarea.ltr-input {
direction: ltr;
text-align: left;
}
+44
View File
@@ -0,0 +1,44 @@
const dateFmt = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
/** ISO date → Jalali date and time, e.g. «۱۴۰۴/۰۷/۲۹، ۱۶:۳۰» */
export const jalali = (iso: string | null | undefined) => (iso ? dateFmt.format(new Date(iso)) : "—");
export const fileSize = (bytes: number) =>
bytes < 1024 * 1024
? `${(bytes / 1024).toLocaleString("fa-IR", { maximumFractionDigits: 0 })} کیلوبایت`
: `${(bytes / 1024 / 1024).toLocaleString("fa-IR", { maximumFractionDigits: 1 })} مگابایت`;
export const faNumber = (n: number) => n.toLocaleString("fa-IR");
/** Website base URL for "view on site" links */
export const SITE_URL = (import.meta.env.VITE_SITE_URL as string | undefined) ?? "http://localhost:3000";
const ACTION_LABELS: Record<string, string> = {
create: "ایجاد",
update: "ویرایش",
delete: "حذف",
upload: "آپلود",
login: "ورود",
publish: "انتشار",
unpublish: "عدم انتشار",
duplicate: "کپی",
reorder: "مرتب‌سازی",
"change-password": "تغییر رمز",
};
const ENTITY_LABELS: Record<string, string> = {
product: "محصول",
category: "دسته",
section: "بخش سایت",
media: "رسانه",
user: "کاربر",
message: "پیام",
};
export const activityLabel = (action: string, entity: string) =>
`${ACTION_LABELS[action] ?? action} ${ENTITY_LABELS[entity] ?? entity}`;
+7
View File
@@ -0,0 +1,7 @@
/** "Safety Shoes 2" → "safety-shoes-2" (Latin letters and digits only) */
export const toSlug = (text: string) =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "ant-design-vue/typings/global"],
"strict": true,
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import { defineConfig, type ProxyOptions } from "vite";
// same paths nginx serves in production: /api → NestJS, /media → object storage bucket
const proxy: Record<string, ProxyOptions> = {
"/api": { target: "http://localhost:4000", changeOrigin: true },
// trailing slash: the media library page itself lives at /media
"/media/": {
target: "http://localhost:9000/imen-media",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/media/, ""),
},
};
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
server: { port: 5173, proxy },
preview: { port: 5173, proxy },
});
+38
View File
@@ -0,0 +1,38 @@
# --- server ---
PORT=4000
NODE_ENV=development
# comma-separated origins allowed to call the API from a browser (admin dev server, site)
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# --- database (docker-compose.dev.yml) ---
DATABASE_URL=postgresql://imen:imen@localhost:5433/imen
# --- auth ---
# generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
JWT_SECRET=change-me-dev-secret-change-me-dev-secret
JWT_ACCESS_TTL=15m
REFRESH_TTL_DAYS=30
# set to false only when serving the admin over plain http in production
COOKIE_SECURE=false
# first admin user, created by `pnpm db:seed`
ADMIN_PHONE=09120000000
ADMIN_PASSWORD=ChangeMe123!
ADMIN_NAME=مدیر سایت
# --- object storage (MinIO in dev, any S3-compatible service in production) ---
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=imen-media
S3_ACCESS_KEY=imen
S3_SECRET_KEY=imen-secret-key
S3_FORCE_PATH_STYLE=true
# create the bucket and make it publicly readable on startup (MinIO)
S3_ENSURE_BUCKET=true
# public base URL of uploaded files (no trailing slash). "/media" is proxied to the bucket
# by the site/admin dev servers and by nginx in production, so URLs are the same everywhere
MEDIA_PUBLIC_URL=/media
# --- website cache revalidation ---
WEB_REVALIDATE_URL=http://localhost:3000/_internal/revalidate
REVALIDATE_SECRET=change-me-revalidate-secret
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@imen/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "prisma generate && nest build",
"dev": "nest start --watch --preserveWatchOutput",
"start": "node dist/main.js",
"typecheck": "prisma generate && tsc --noEmit -p tsconfig.json",
"test": "vitest run --config ./vitest.config.e2e.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"db:seed": "prisma db seed",
"postinstall": "prisma generate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1154.0",
"@imen/shared": "workspace:*",
"@nestjs/common": "^12.2.0",
"@nestjs/core": "^12.2.0",
"@nestjs/jwt": "^12.0.3",
"@nestjs/platform-express": "^12.2.0",
"@nestjs/throttler": "^6.7.1",
"@node-rs/argon2": "^2.2.2",
"@prisma/adapter-pg": "7.10.1",
"@prisma/client": "7.10.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sharp": "^0.35.5",
"zod": "^4.6.7"
},
"devDependencies": {
"@nestjs/cli": "^12.0.10",
"@nestjs/schematics": "^12.0.10",
"@nestjs/testing": "^12.2.0",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.6",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"prisma": "7.10.1",
"supertest": "^7.1.4",
"tsx": "^4.20.6",
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.2"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { existsSync } from "node:fs";
import { defineConfig } from "prisma/config";
// Prisma 7 no longer loads .env on its own
if (existsSync(".env")) process.loadEnvFile(".env");
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx src/seed/seed.ts",
},
datasource: {
// `prisma generate` (e.g. in the Docker build) does not need a real database
url: process.env.DATABASE_URL ?? "postgresql://placeholder:placeholder@localhost:5432/placeholder",
},
});
@@ -0,0 +1,212 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR');
-- CreateEnum
CREATE TYPE "MessageStatus" AS ENUM ('NEW', 'READ', 'ARCHIVED');
-- CreateTable
CREATE TABLE "AdminUser" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'EDITOR',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"userAgent" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Media" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"mime" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"width" INTEGER,
"height" INTEGER,
"alt" TEXT NOT NULL DEFAULT '',
"originalName" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" SERIAL NOT NULL,
"slug" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameEn" TEXT NOT NULL DEFAULT '',
"shortDesc" TEXT NOT NULL DEFAULT '',
"fullDesc" TEXT NOT NULL DEFAULT '',
"icon" TEXT NOT NULL DEFAULT '',
"coverImageId" INTEGER,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"seoTitle" TEXT NOT NULL DEFAULT '',
"seoDescription" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductGroup" (
"id" SERIAL NOT NULL,
"categoryId" INTEGER NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"desc" TEXT NOT NULL DEFAULT '',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ProductGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" SERIAL NOT NULL,
"categoryId" INTEGER NOT NULL,
"groupId" INTEGER,
"code" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameEn" TEXT NOT NULL DEFAULT '',
"desc" TEXT NOT NULL DEFAULT '',
"specs" TEXT[],
"variants" TEXT[],
"imageId" INTEGER,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductImage" (
"productId" INTEGER NOT NULL,
"mediaId" INTEGER NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("productId","mediaId")
);
-- CreateTable
CREATE TABLE "SiteSection" (
"key" TEXT NOT NULL,
"data" JSONB NOT NULL,
"updatedById" INTEGER,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SiteSection_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "ContactMessage" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"org" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"message" TEXT NOT NULL DEFAULT '',
"status" "MessageStatus" NOT NULL DEFAULT 'NEW',
"ip" TEXT NOT NULL DEFAULT '',
"userAgent" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ContactMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" SERIAL NOT NULL,
"userId" INTEGER,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT NOT NULL DEFAULT '',
"summary" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Media_key_key" ON "Media"("key");
-- CreateIndex
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- CreateIndex
CREATE INDEX "Category_sortOrder_idx" ON "Category"("sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "ProductGroup_categoryId_slug_key" ON "ProductGroup"("categoryId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
-- CreateIndex
CREATE INDEX "Product_categoryId_sortOrder_idx" ON "Product"("categoryId", "sortOrder");
-- CreateIndex
CREATE INDEX "ContactMessage_status_createdAt_idx" ON "ContactMessage"("status", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductGroup" ADD CONSTRAINT "ProductGroup_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ProductGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "applications" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "attributes" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "brand" TEXT NOT NULL DEFAULT '',
ADD COLUMN "details" TEXT NOT NULL DEFAULT '',
ADD COLUMN "features" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "minOrder" TEXT NOT NULL DEFAULT '',
ADD COLUMN "origin" TEXT NOT NULL DEFAULT '',
ADD COLUMN "packaging" TEXT NOT NULL DEFAULT '',
ADD COLUMN "seoDescription" TEXT NOT NULL DEFAULT '',
ADD COLUMN "seoTitle" TEXT NOT NULL DEFAULT '',
ADD COLUMN "sizes" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "standards" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,3 @@
-- Rename login identifier from email to phone
ALTER TABLE "AdminUser" RENAME COLUMN "email" TO "phone";
ALTER INDEX "AdminUser_email_key" RENAME TO "AdminUser_phone_key";
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+188
View File
@@ -0,0 +1,188 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
importFileExtension = "js"
}
datasource db {
provider = "postgresql"
}
enum Role {
ADMIN
EDITOR
}
enum MessageStatus {
NEW
READ
ARCHIVED
}
model AdminUser {
id Int @id @default(autoincrement())
phone String @unique
name String
passwordHash String
role Role @default(EDITOR)
isActive Boolean @default(true)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
refreshTokens RefreshToken[]
auditLogs AuditLog[]
}
model RefreshToken {
id Int @id @default(autoincrement())
userId Int
user AdminUser @relation(fields: [userId], references: [id], onDelete: Cascade)
/// sha256 of the opaque token stored in the httpOnly cookie
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
userAgent String @default("")
createdAt DateTime @default(now())
@@index([userId])
}
model Media {
id Int @id @default(autoincrement())
/// object key in the S3 bucket; the public URL is MEDIA_PUBLIC_URL + "/" + key
key String @unique
mime String
size Int
width Int?
height Int?
alt String @default("")
originalName String @default("")
createdAt DateTime @default(now())
categoryCovers Category[] @relation("CategoryCover")
productImages Product[] @relation("ProductImage")
productGallery ProductImage[]
}
model Category {
id Int @id @default(autoincrement())
slug String @unique
code String
name String
nameEn String @default("")
shortDesc String @default("")
fullDesc String @default("")
/// Lucide icon name
icon String @default("")
coverImageId Int?
coverImage Media? @relation("CategoryCover", fields: [coverImageId], references: [id], onDelete: SetNull)
sortOrder Int @default(0)
isPublished Boolean @default(true)
seoTitle String @default("")
seoDescription String @default("")
groups ProductGroup[]
products Product[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sortOrder])
}
model ProductGroup {
id Int @id @default(autoincrement())
categoryId Int
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
slug String
name String
desc String @default("")
sortOrder Int @default(0)
products Product[]
@@unique([categoryId, slug])
}
model Product {
id Int @id @default(autoincrement())
categoryId Int
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
groupId Int?
group ProductGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
code String @unique
slug String @unique
name String
nameEn String @default("")
desc String @default("")
specs String[]
variants String[]
/// long description for the product page; a blank line starts a new paragraph
details String @default("")
brand String @default("")
/// country of manufacture
origin String @default("")
sizes String[] @default([])
standards String[] @default([])
features String[] @default([])
applications String[] @default([])
/// technical specification table: [{ "name": "...", "value": "..." }]
attributes Json @default("[]")
packaging String @default("")
minOrder String @default("")
seoTitle String @default("")
seoDescription String @default("")
imageId Int?
image Media? @relation("ProductImage", fields: [imageId], references: [id], onDelete: SetNull)
gallery ProductImage[]
sortOrder Int @default(0)
isPublished Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([categoryId, sortOrder])
}
model ProductImage {
productId Int
mediaId Int
sortOrder Int @default(0)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
@@id([productId, mediaId])
}
/// One row per editable website section; `data` is validated by the zod schema for `key` (@imen/shared)
model SiteSection {
key String @id
data Json
updatedById Int?
updatedAt DateTime @updatedAt
}
model ContactMessage {
id Int @id @default(autoincrement())
name String
org String
phone String
message String @default("")
status MessageStatus @default(NEW)
ip String @default("")
userAgent String @default("")
createdAt DateTime @default(now())
@@index([status, createdAt])
}
model AuditLog {
id Int @id @default(autoincrement())
userId Int?
user AdminUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
/// create | update | delete | login ...
action String
/// product | category | section | media | user | message
entity String
entityId String @default("")
summary String @default("")
createdAt DateTime @default(now())
@@index([createdAt])
}

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 265 KiB

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