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>
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
out
|
||||
dist
|
||||
.output
|
||||
.nuxt
|
||||
pnpm-lock.yaml
|
||||
apps/api/src/generated
|
||||
محصولات
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -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;"]
|
||||
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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 "خطای ناشناخته";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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} | پنل ایمن پیشگامان` : "پنل ایمن پیشگامان";
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 |