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>
This commit is contained in:
S. Esmaeili
2026-10-21 20:31:05 +03:30
co-authored by Claude Opus 5.5
parent 31076174e2
commit 789d224ea0
395 changed files with 19070 additions and 11592 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_EMAIL=admin@imenpishgaman.ir
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;"]
+151 -146
View File
@@ -1,149 +1,154 @@
# آریا ایمن — لندینگ (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_EMAIL / 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`.
## پنل مدیریت
- **محصولات**: افزودن/ویرایش، آپلود تصویر اصلی و گالری، ویژگی‌ها و مدل‌ها، انتشار یا پیش‌نویس، کپی، مرتب‌سازی (با فیلتر یک دسته).
- **دسته‌بندی‌ها**: کاور، زیرگروه‌ها (فیلترهای صفحه‌ی دسته)، سئو، ترتیب نمایش.
- **بخش‌های سایت**: 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 نیازی نیست.
## کارهای باقی‌مانده و پیشنهادها
- **صفحه‌ی جداگانه برای هر محصول** (`/products/[slug]`) با JSON-LD نوع Product. فیلد `slug` و گالری محصول از الان در دیتابیس هست.
- **ایمپورت/اکسپورت اکسل** محصولات برای ورود دسته‌جمعی.
- **اعلان پیام تماس جدید** با ایمیل، پیامک یا ربات بله/تلگرام.
- پوشه‌ی `محصولات/` (عکس‌ها و فایل‌های 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`).
-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,
});
}
-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

+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 "خطای ناشناخته";
}
+65
View File
@@ -0,0 +1,65 @@
import type { MediaDto, 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[];
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;
}
+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 { DeleteOutlined, PictureOutlined } from "@ant-design/icons-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" />
<PictureOutlined v-else class="text-2xl text-gray-300" />
</div>
<a-space direction="vertical">
<a-button @click="open = true">
<template #icon><PictureOutlined /></template>
{{ url ? "تغییر تصویر" : "انتخاب یا آپلود تصویر" }}
</a-button>
<a-button v-if="url && clearable" danger @click="emit('clear')">
<template #icon><DeleteOutlined /></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>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts" generic="T">
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons-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><ArrowUpOutlined /></template>
</a-button>
<a-button size="small" :disabled="i === items.length - 1" title="پایین" @click="move(i, 1)">
<template #icon><ArrowDownOutlined /></template>
</a-button>
<a-popconfirm title="این مورد حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(i)">
<a-button size="small" danger title="حذف">
<template #icon><DeleteOutlined /></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><PlusOutlined /></template>
{{ addLabel }}
</a-button>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { InboxOutlined } from "@ant-design/icons-vue";
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { ref, watch } from "vue";
import { api, errorMessage } from "@/api/http";
/** 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"><InboxOutlined /></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>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{ title: string; subtitle?: string; back?: string }>();
</script>
<template>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<RouterLink v-if="back" :to="back" class="text-gray-400 hover:text-gray-700" title="بازگشت">→</RouterLink>
<div>
<h1 class="text-xl font-bold text-gray-800">{{ title }}</h1>
<p v-if="subtitle" class="mt-1 text-sm text-gray-500">{{ subtitle }}</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<slot />
</div>
</div>
</template>
@@ -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>
+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,
};
+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 };
}
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import {
AppstoreOutlined,
DashboardOutlined,
ExportOutlined,
FileImageOutlined,
LayoutOutlined,
LogoutOutlined,
MailOutlined,
MenuOutlined,
ShoppingOutlined,
TeamOutlined,
UserOutlined,
} from "@ant-design/icons-vue";
import { ROLE_LABELS } from "@imen/shared";
import { computed, h, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { SITE_URL } from "@/utils/format";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const drawer = ref(false);
const items = computed(() => [
{ key: "dashboard", icon: () => h(DashboardOutlined), label: "داشبورد" },
{ key: "products", icon: () => h(ShoppingOutlined), label: "محصولات" },
{ key: "categories", icon: () => h(AppstoreOutlined), label: "دسته‌بندی‌ها" },
{ key: "sections", icon: () => h(LayoutOutlined), label: "بخش‌های سایت" },
{ key: "messages", icon: () => h(MailOutlined), label: "پیام‌های تماس" },
{ key: "media", icon: () => h(FileImageOutlined), label: "کتابخانه رسانه" },
...(auth.isAdmin ? [{ key: "users", icon: () => h(TeamOutlined), 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 selected = computed(() => {
const name = String(route.name ?? "");
return [MENU_OF[name] ?? name];
});
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="min-h-screen">
<a-layout-sider
:width="232"
breakpoint="lg"
collapsed-width="0"
:trigger="null"
class="!hidden lg:!block"
style="background: #0b1224"
>
<div class="flex items-center gap-3 px-5 py-5">
<img src="/favicon.svg" alt="" class="size-8" />
<div class="leading-tight text-white">
<div class="font-bold">ایمن پیشگامان</div>
<div class="text-[11px] text-white/50">پنل مدیریت</div>
</div>
</div>
<a-menu
theme="dark"
mode="inline"
:selected-keys="selected"
:items="items"
style="background: transparent; border: 0"
@click="go"
/>
</a-layout-sider>
<a-drawer v-model:open="drawer" placement="right" :width="260" :body-style="{ padding: 0, background: '#0b1224' }" :closable="false">
<a-menu theme="dark" mode="inline" :selected-keys="selected" :items="items" style="background: transparent; border: 0" @click="go" />
</a-drawer>
<a-layout>
<a-layout-header
class="flex items-center justify-between gap-4 border-b border-gray-200 !bg-white !px-4 md:!px-6"
style="height: 60px; line-height: 60px"
>
<div class="flex items-center gap-3">
<a-button class="lg:!hidden" @click="drawer = true">
<template #icon><MenuOutlined /></template>
</a-button>
<a :href="SITE_URL" target="_blank" rel="noopener" class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-[#ff5a00]">
<ExportOutlined />
مشاهده سایت
</a>
</div>
<a-dropdown placement="bottomLeft">
<a-button type="text" class="flex items-center gap-2">
<UserOutlined />
<span>{{ auth.user?.name }}</span>
<a-tag v-if="auth.user" class="!m-0" :color="auth.isAdmin ? 'orange' : 'blue'">
{{ ROLE_LABELS[auth.user.role] }}
</a-tag>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="profile" @click="router.push({ name: 'profile' })">
<UserOutlined /> حساب کاربری
</a-menu-item>
<a-menu-item key="logout" danger @click="auth.logout()">
<LogoutOutlined /> خروج
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-layout-header>
<a-layout-content class="p-4 md:p-6">
<!-- keyed by path (not query) so /sections/hero → /sections/about gets a fresh page -->
<RouterView v-slot="{ Component, route: r }">
<component :is="Component" :key="r.path" />
</RouterView>
</a-layout-content>
</a-layout>
</a-layout>
</template>
+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");
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
EditOutlined,
ExportOutlined,
PlusOutlined,
} from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
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><PlusOutlined /></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><ArrowUpOutlined /></template>
</a-button>
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
<template #icon><ArrowDownOutlined /></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><EditOutlined /></template></a-button>
</RouterLink>
<a :href="`${SITE_URL}/categories/${record.slug}`" target="_blank" rel="noopener">
<a-button size="small" title="مشاهده در سایت"><template #icon><ExportOutlined /></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><DeleteOutlined /></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 { SaveOutlined } from "@ant-design/icons-vue";
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 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><SaveOutlined /></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 { PlusOutlined } from "@ant-design/icons-vue";
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 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><PlusOutlined /></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>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { LockOutlined, MailOutlined } from "@ant-design/icons-vue";
import { loginSchema } from "@imen/shared";
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import FormField from "@/components/FormField.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, { email: "", 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="flex min-h-screen items-center justify-center bg-[#0b1224] p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-2xl">
<div class="mb-8 text-center">
<img src="/favicon.svg" alt="" class="mx-auto size-12" />
<h1 class="mt-4 text-xl font-bold text-gray-800">پنل مدیریت ایمن پیشگامان</h1>
<p class="mt-1 text-sm text-gray-500">برای ادامه وارد شوید</p>
</div>
<a-form layout="vertical" @submit.prevent="submit">
<FormField path="email" label="ایمیل">
<a-input v-model:value="model.email" size="large" class="ltr-input" autocomplete="username" autofocus>
<template #prefix><MailOutlined class="text-gray-400" /></template>
</a-input>
</FormField>
<FormField path="password" label="رمز عبور">
<a-input-password v-model:value="model.password" size="large" class="ltr-input" autocomplete="current-password">
<template #prefix><LockOutlined class="text-gray-400" /></template>
</a-input-password>
</FormField>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">ورود</a-button>
</a-form>
</div>
</div>
</template>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { CopyOutlined, DeleteOutlined, UploadOutlined } from "@ant-design/icons-vue";
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 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><UploadOutlined /></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><CopyOutlined /></template>کپی آدرس</a-button>
<a-popconfirm title="این تصویر حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove">
<a-button danger><template #icon><DeleteOutlined /></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 { DeleteOutlined } from "@ant-design/icons-vue";
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 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><DeleteOutlined /></template></a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
</template>
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { CloseOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons-vue";
import { type MediaDto, 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 type { AdminCategory, AdminCategoryDetail, AdminProductDetail } from "@/api/types";
import FormField from "@/components/FormField.vue";
import ImageField from "@/components/ImageField.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: [],
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),
});
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,
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 && model.isPublished"
:href="`${SITE_URL}/categories/${category.slug}`"
target="_blank"
rel="noopener"
>
<a-button>مشاهده در سایت</a-button>
</a>
<a-button type="primary" :loading="saving" @click="save">
<template #icon><SaveOutlined /></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>
<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="توضیحات" class="md:col-span-2">
<a-textarea v-model:value="model.desc" :auto-size="{ minRows: 3, maxRows: 8 }" />
</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>
<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="گالری تصاویر (اختیاری)">
<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)"
>
<CloseOutlined 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"
>
<PlusOutlined />
</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>
</div>
</div>
</a-form>
</a-spin>
</template>
+266
View File
@@ -0,0 +1,266 @@
<script setup lang="ts">
import {
ArrowDownOutlined,
ArrowUpOutlined,
CopyOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
} from "@ant-design/icons-vue";
import type { Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, 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 PageHeader from "@/components/PageHeader.vue";
import { faNumber } from "@/utils/format";
const route = useRoute();
const router = useRouter();
const num = (v: unknown) => (v ? Number(v) : undefined);
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,
});
const rows = ref<AdminProduct[]>([]);
const total = ref(0);
const loading = ref(false);
const categories = ref<AdminCategory[]>([]);
const groups = ref<AdminCategoryDetail["groups"]>([]);
/** 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 pageSize = computed(() => (reorderable.value ? 200 : 30));
async function load() {
loading.value = true;
void router.replace({
query: Object.fromEntries(Object.entries(filters).filter(([k, v]) => v && !(k === "page" && v === 1))),
});
try {
const res = await api<Paginated<AdminProduct>>("/admin/products", {
query: { ...filters, q: filters.q || undefined, pageSize: pageSize.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 },
]);
onMounted(async () => {
try {
categories.value = await api<AdminCategory[]>("/admin/categories");
await loadGroups();
} catch (e) {
void message.error(errorMessage(e));
}
await load();
});
</script>
<template>
<PageHeader title="محصولات" :subtitle="`${faNumber(total)} محصول`">
<RouterLink :to="{ name: 'product-new', query: filters.categoryId ? { categoryId: filters.categoryId } : {} }">
<a-button type="primary">
<template #icon><PlusOutlined /></template>
محصول جدید
</a-button>
</RouterLink>
</PageHeader>
<a-card class="mb-4" :body-style="{ padding: '16px' }">
<div class="grid gap-3 md:grid-cols-4">
<a-select
v-model:value="filters.categoryId"
allow-clear
placeholder="همه دسته‌ها"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
/>
<a-select
v-model:value="filters.groupId"
allow-clear
placeholder="همه زیرگروه‌ها"
:disabled="!groups.length"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
<a-select
v-model:value="filters.status"
allow-clear
placeholder="همه وضعیت‌ها"
:options="[
{ value: 'published', label: 'منتشرشده' },
{ value: 'draft', label: 'پیش‌نویس / مخفی' },
]"
/>
<a-input-search
v-model:value="filters.q"
allow-clear
placeholder="جستجوی نام یا کد"
@search="() => { filters.page = 1; load(); }"
/>
</div>
<p v-if="filters.categoryId && !reorderable" class="mt-3 text-xs text-gray-400">
برای تغییر ترتیب محصولات، فقط فیلتر دسته را نگه دارید.
</p>
</a-card>
<a-card :body-style="{ padding: 0 }">
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
row-key="id"
:scroll="{ x: 760 }"
:pagination="reorderable ? false : {
current: filters.page,
pageSize,
total,
showSizeChanger: false,
onChange: (p: number) => { filters.page = p; load(); },
}"
>
<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">
{{ 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><ArrowUpOutlined /></template>
</a-button>
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
<template #icon><ArrowDownOutlined /></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><EditOutlined /></template></a-button>
</RouterLink>
<a-button size="small" title="کپی" @click="duplicate(record as AdminProduct)">
<template #icon><CopyOutlined /></template>
</a-button>
<a-popconfirm title="این محصول حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record as AdminProduct)">
<a-button size="small" danger title="حذف"><template #icon><DeleteOutlined /></template></a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</template>
+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?.email }}</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 { SaveOutlined } from "@ant-design/icons-vue";
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 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><SaveOutlined /></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>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons-vue";
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 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 = { email: "", 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({ email: u.email, 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: "email" },
{ 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><PlusOutlined /></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 === 'email'"><span dir="ltr">{{ record.email }}</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><EditOutlined /></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><DeleteOutlined /></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="email" label="ایمیل" required>
<a-input v-model:value="model.email" class="ltr-input" :disabled="!isNew" />
</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);
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "ant-design-vue/typings/global"],
"strict": 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"]
}
+23
View File
@@ -0,0 +1,23 @@
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 },
"/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_EMAIL=admin@imenpishgaman.ir
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,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+173
View File
@@ -0,0 +1,173 @@
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())
email 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[]
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

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

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