Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d25db9367c | ||
|
|
1d29e50852 | ||
|
|
851e3fc2dc | ||
|
|
74770cd99d | ||
|
|
ce0735d775 | ||
|
|
805f4594d4 | ||
|
|
fe6957bf83 | ||
|
|
53673d0f9b | ||
|
|
9d55f521a0 | ||
|
|
4fb57dfd67 | ||
|
|
6b49d035c2 | ||
|
|
6bb6226e06 | ||
|
|
9242b87677 |
@@ -1,4 +1,5 @@
|
||||
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
|
||||
# راه سادهتر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی میسازد.
|
||||
# docker compose بهطور خودکار این فایل را میخواند؛ بدون آن استک بالا نمیآید.
|
||||
|
||||
# --- رمزهای اصلی (اجباری) ---
|
||||
@@ -14,10 +15,57 @@ ADMIN_PASSWORD=change-me-admin-password
|
||||
# پورت ورود Traefik در توسعه (پیشفرض 5173)
|
||||
TRAEFIK_HTTP_PORT=5173
|
||||
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
|
||||
# در تولید این مقدار هم پایهٔ پروب بیروننگر مانیتورینگ است.
|
||||
SITE_URL=
|
||||
# آدرسهای مجاز CORS (لیست JSON) — پیشفرض فقط لوکالهاست
|
||||
# CORS_ORIGINS=["http://localhost:5173"]
|
||||
|
||||
# --- بیلد فرانتاند (در زمان npm run build روی هاست خوانده میشود — stack.sh پاس میدهد) ---
|
||||
# اگر API روی مبدأ دیگری است (مثل توسعه ویندوز با پورت 19001) تنظیم کنید؛ هممبدأ یعنی خالی
|
||||
VITE_API_URL=
|
||||
# اسکریپت ردیاب Umami — خالی یعنی آنالیتیکس خاموش؛ در تولید مثل:
|
||||
# VITE_UMAMI_SRC=https://analytics.aramesh.example/script.js
|
||||
VITE_UMAMI_SRC=
|
||||
# شناسهٔ وبسایت از داشبورد Umami (اولین بار: وبسایت را در analytics.localhost بسازید)
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
|
||||
# --- چتبات (دستیار هوشمند) ---
|
||||
# LLM سازگار با OpenAI — اجباری برای پاسخگویی چتبات؛ خالی یعنی سرویس بالا میآید اما پاسخ نمیدهد
|
||||
LLM_BASE_URL=
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=openai/gpt-oss-120b
|
||||
# توکن محافظت از POST /chat/reindex — خالی یعنی فقط از شبکه داخلی
|
||||
CHAT_REINDEX_TOKEN=
|
||||
|
||||
# --- مانیتورینگ (docker-compose.monitoring.yml) ---
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=change-me-grafana-password
|
||||
# هشدارها به بله — توکن بات (همان شکل تلگرام؛ از BotFather بله) و شناسه چت/گروه
|
||||
# شناسه چت: به بات یک پیام بدهید سپس:
|
||||
# curl "https://tapi.bale.ai/bot<توکن>/getUpdates"
|
||||
ALERT_BOT_TOKEN=
|
||||
ALERT_CHAT_ID=0
|
||||
|
||||
# --- عملیات ---
|
||||
# نگهداری پشتیبانهای شبانه (روز) — scripts/backup.sh
|
||||
RETENTION_DAYS=14
|
||||
# انتشار پشتیبان خارج از سرور (rclone remote مثل storage:aramesh-backups) — خالی یعنی خاموش
|
||||
OFFSITE_RCLONE_REMOTE=
|
||||
|
||||
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
||||
# DOMAIN=aramesh.example
|
||||
# ACME_EMAIL=admin@aramesh.example
|
||||
# دامنه دوم (در صورت وجود) — مسیرها و گواهی TLS برای هر دو صادر میشود
|
||||
# DOMAIN_ALIAS=aramesh.example.ir
|
||||
|
||||
# --- پیشنمایش خصوصی و انتشار (scripts/preview-mode.sh) ---
|
||||
# «on» یعنی سایت آنلاین است اما پشت گیت ورود و بدون ایندکس؛ «off» یعنی انتشار عمومی.
|
||||
# این مقادیر را دستی ویرایش نکنید — اسکریپت preview-mode.sh هماهنگشان میکند:
|
||||
# bash scripts/preview-mode.sh on|off|status → bash scripts/stack.sh deploy-prod
|
||||
# PREVIEW_MODE=on
|
||||
# هش htpasswd کاربر مشترک پیشنمایش — اولین «on» خودش میسازد
|
||||
# PREVIEW_AUTH_USERS=preview:$apr1$...
|
||||
# فهرست میانافزارهای فعال؛ «on» پرش میکند و «off» خالیش میکند
|
||||
# PREVIEW_MIDDLEWARES=preview-auth@docker,preview-noindex@docker
|
||||
# در انتشار، دامنه دوم را به اصلی ریدایرکت میکند تا محتوای تکراری ساخته نشود
|
||||
# ALIAS_REDIRECT_MIDDLEWARES=to-primary@docker
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# اسکریپتهای شل باید در لینوکس هم LF باشند — CRLF در bash میشکند
|
||||
*.sh text eol=lf
|
||||
*.yml text
|
||||
*.yaml text
|
||||
*.json text
|
||||
Dockerfile text
|
||||
*.py text
|
||||
@@ -0,0 +1,47 @@
|
||||
name: CI
|
||||
|
||||
# گیت سرور self-hosted شما (Gitea Actions / Forgejo) همین سینتکس را اجرا میکند؛
|
||||
# روی GitHub هم بیتغییر کار میکند.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install backend deps
|
||||
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: pytest -q
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Frontend typecheck/build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build images
|
||||
run: docker compose -f docker-compose.yml build backend frontend chatbot
|
||||
@@ -9,6 +9,12 @@ env/
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
|
||||
monitoring/alertmanager/alertmanager.rendered.yml
|
||||
|
||||
# تنظیمات محلی هر ماشین
|
||||
docker-compose.override.yml
|
||||
|
||||
# پشتیبانگیری محلی
|
||||
backups/
|
||||
|
||||
@@ -22,6 +28,7 @@ dist/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.zcode/
|
||||
|
||||
# Database
|
||||
*.sqlite3
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
||||
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL (pgvector) + JWT + MinIO
|
||||
- **`chatbot/`** — دستیار هوشمند (FastAPI + LangGraph + RAG با pgvector، پاسخ استریمی)
|
||||
- **مانیتورینگ و آنالیتیکس** — Prometheus/Grafana/Loki و Umami بهصورت لایههای compose
|
||||
|
||||
## معماری
|
||||
|
||||
```
|
||||
Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند)
|
||||
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
|
||||
Traefik (تنها نقطه ورود — 80/443 در تولید)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند) + متریک /metrics فقط داخلی
|
||||
├── /media → MinIO (باکت aramesh-media)
|
||||
├── /chat → چتبات (SSE استریم پاسخ)
|
||||
├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
|
||||
└── analytics.* → Umami (آنالیتیکس محصول)
|
||||
```
|
||||
|
||||
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
|
||||
@@ -19,107 +24,213 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
|
||||
`/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
|
||||
- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
|
||||
- شبکههای compose تفکیک شدهاند: `edge` (سرویسهای پشت Traefik) و `internal`
|
||||
(دیتابیس/ذخیرهسازی) — فرانتاند هیچ راه مستقیمی به دیتابیس ندارد
|
||||
- **لاگها**: json-file با سقف ۱۰MB×۳ برای هر سرویس؛ علاوه بر آن Promtail همه را به
|
||||
Loki میفرستد تا در Grafana جستوجو شوند
|
||||
|
||||
## ساختار
|
||||
|
||||
```
|
||||
├── frontend/ # سایت React
|
||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
|
||||
│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
|
||||
│ ├── tests/ # تستهای API (pytest)
|
||||
│ ├── requirements.txt
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
|
||||
├── docker-compose.yml # اجرای کامل با یک دستور
|
||||
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
|
||||
├── frontend/ # سایت React (بیلد روی هاست — ایمیج فقط سرو میکند)
|
||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||
│ ├── Caddyfile # سرو استاتیک + کش immutable برای /assets/*
|
||||
│ └── Dockerfile # Caddy — بدون root
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع (آپلود: app/api/admin.py — سئو: app/seo_pages.py)
|
||||
│ ├── migrations/ # مهاجرتهای نسخهدار Alembic
|
||||
│ ├── tests/ # تستهای API (pytest)
|
||||
│ ├── alembic.ini
|
||||
│ └── Dockerfile # بدون root — خاموشی تمیز با exec
|
||||
├── chatbot/ # دستیار هوشمند (RAG + pgvector)
|
||||
├── monitoring/ # پیکربندی Prometheus/Alertmanager/Loki/Promtail/Grafana
|
||||
├── scripts/
|
||||
│ ├── stack.sh # ابزار اصلی عملیات — deploy/logs/health/backup/restore/update
|
||||
│ ├── backup.sh # پشتیبان کامل (DB + تصاویر) با چکسام و گزینه انتشار بیرونی
|
||||
│ ├── restore.sh # بازگردانی پشتیبان دیتابیس
|
||||
│ ├── gen-env.sh # ساخت .env با رمزهای قوی تصادفی
|
||||
│ ├── audit.sh # ممیزی آسیبپذیری وابستگیها و ایمیجها
|
||||
│ ├── migrate-media.py # انتقال یکباره تصاویر CDN خارجی به MinIO
|
||||
│ └── ci.sh # تست + بیلد ایمیج (محلی یا هر CI)
|
||||
├── docker-compose.yml # استک اصلی (شامل چتبات و Umami)
|
||||
├── docker-compose.monitoring.yml # لایه مانیتورینگ
|
||||
├── docker-compose.prod.yml # لایه تولید: TLS، دامنه، سختسازی پورتها
|
||||
└── docker-compose.override.yml # تنظیمات محلی این ماشین (در گیت نیست)
|
||||
```
|
||||
|
||||
## اجرا با Docker (سادهترین راه)
|
||||
## اجرا با Docker
|
||||
|
||||
پیشنیاز: Docker Desktop. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
|
||||
`dist/` را سرو میکند)، پس اولین بار باید یکبار `npm run build` بزنید:
|
||||
`dist/` را سرو میکند) — `scripts/stack.sh` همین را به یک دستور تبدیل میکند:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
docker compose up -d --build
|
||||
cp .env.example .env # یا: bash scripts/gen-env.sh (رمزهای قوی تصادفی)
|
||||
cd frontend && npm install && cd ..
|
||||
bash scripts/stack.sh deploy # بیلد فرانت + بیلد و بالا آوردن استک + انتظار برای سلامت
|
||||
```
|
||||
|
||||
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`)
|
||||
|
||||
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
|
||||
- سایت: http://localhost:18081 (پورت از `TRAEFIK_HTTP_PORT` در `.env`) — ورود مدیریت: `/admin/login`
|
||||
- دستورهای عملیات: `bash scripts/stack.sh` — `logs`، `ps`، `health`، `backup`، `restore`، `update`، `seed-force`
|
||||
- مستندات API: http://localhost:19001/docs (با `docker-compose.override.yml` محلی)
|
||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
|
||||
- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
|
||||
ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد، خواندن عمومی را
|
||||
فعال میکند و تصاویر داخل `seed-media/` را در باکت میریزد — پس یک کلون تازه هم
|
||||
همان تصاویر سایت را دارد
|
||||
- دیتابیس PostgreSQL (تصویر pgvector)، آبجکتاستور MinIO و کش مدلهای چتبات هر یک
|
||||
در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، جداول پایه را میسازد، **مهاجرتهای Alembic** را اعمال میکند و
|
||||
**فقط جدولهای خالی** را seed میکند — ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- `minio-setup` باکت `aramesh-media` را میسازد و فقط **وقتی باکت خالی است** تصاویر
|
||||
`seed-media/` را میریزد — `db-init` هم دیتابیسهای `umami` و `aramesh_chatbot` را
|
||||
روی همان نمونهٔ Postgres میسازد
|
||||
|
||||
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
|
||||
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
|
||||
>
|
||||
> پایهٔ API فرانتاند در زمان بیلد از `VITE_API_URL` در `.env` خوانده میشود —
|
||||
> در توسعه ویندوزیِ این ماشین `VITE_API_URL=http://localhost:19001` بگذارید.
|
||||
|
||||
## انتشار واقعی (TLS)
|
||||
|
||||
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
|
||||
پیشنیاز: رکوردهای DNS (`example.com`، `analytics.example.com` و اگر مانیتورینگ فعال
|
||||
است `grafana.example.com`) به این سرور، سپس در `.env`: `DOMAIN`، `ACME_EMAIL` و
|
||||
`SITE_URL=https://example.com`:
|
||||
|
||||
```bash
|
||||
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
bash scripts/stack.sh deploy-prod
|
||||
```
|
||||
|
||||
- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
|
||||
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرسهای OG مطلق شوند
|
||||
- **پورتهای مدیریتی MinIO (۹۰۰۱ کنسول و ۱۹۰۰۰ API) در تولید فقط روی `127.0.0.1`
|
||||
بسته میشوند** — دسترسی از راه دور فقط با تونل SSH:
|
||||
`ssh -L 9001:127.0.0.1:9001 server`
|
||||
- همه روترهای websecure هدرهای امنیتی میگیرند (HSTS + nosniff)
|
||||
|
||||
## اجرای محلی (توسعه)
|
||||
### پیشنمایش خصوصی (کار روی سایت، قبل از انتشار عمومی)
|
||||
|
||||
### بکاند
|
||||
سایت روی اینترنت بالا میآید و تیم با همان پنل مدیریت محتوا را ویرایش میکند
|
||||
(ویرایش درجای ایمیل/تلفن/متنها و نوشتن مقاله)، اما **نه عموم به آن دسترسی دارند و
|
||||
نه گوگل چیزی ایندکس میکند**. دو لایه محافظت:
|
||||
|
||||
1. **گیت ورود Traefik (Basic Auth)** — همه مسیرها (سایت، API، تصاویر و چت) پشت یک
|
||||
نام کاربری/رمز مشترکاند؛ بدون آن فقط ۴۰۱ برمیگردد
|
||||
2. **noindex** — هدر `X-Robots-Tag` روی همه پاسخها + `robots.txt` مسدودکننده +
|
||||
نقشه سایت خالی؛ حتی اگر گیت برداشته شود، گوگل چیزی ذخیره نمیکند
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
|
||||
python -m app.seed # ساخت جداول + محتوای اولیه
|
||||
uvicorn app.main:app --reload
|
||||
bash scripts/preview-mode.sh on # فعالسازی (بار اول نام کاربری/رمز میسازد و چاپ میکند)
|
||||
bash scripts/stack.sh deploy-prod # اعمال
|
||||
bash scripts/preview-mode.sh status # وضعیت فعلی
|
||||
```
|
||||
|
||||
### فرانتاند
|
||||
- دامنه اصلی (`DOMAIN`) و دامنه دوم (`DOMAIN_ALIAS`) هر دو سرو میشوند و هر دو گیت دارند
|
||||
- برای دادن دسترسی به کارفرما، همان نام کاربری/رمز مشترک را بدهید
|
||||
- رمز مشترک در `.env` (`PREVIEW_AUTH_USERS`) میماند — فقط یکبار چاپ میشود
|
||||
|
||||
### انتشار عمومی (برداشتن پیشنمایش)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
bash scripts/preview-mode.sh off # حذف گیت ورود + اجازه ایندکس
|
||||
bash scripts/stack.sh deploy-prod
|
||||
```
|
||||
|
||||
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
|
||||
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
|
||||
`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||
بعد از این مرحله:
|
||||
|
||||
1. دامنه دوم بهصورت ۳۰۱ دائمی به دامنه اصلی ریدایرکت میشود (جلوگیری از محتوای تکراری)
|
||||
2. در Search Console دامنه را تأیید و `https://DOMAIN/sitemap.xml` را ثبت کنید
|
||||
3. اگر پیشتر چیزی ایندکس شده بود، حذفش را از همان کنسول درخواست کنید
|
||||
|
||||
### چکلیست رفتن به تولید
|
||||
|
||||
1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
|
||||
2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
|
||||
3. `DOMAIN`، `DOMAIN_ALIAS` (دامنه دوم)، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
|
||||
4. `bash scripts/preview-mode.sh on` — اولین انتشار بهصورت پیشنمایش خصوصی میرود؛
|
||||
محتوا را روی سایت آنلاین نهایی کنید و بعد `preview-mode.sh off` + deploy مجدد
|
||||
5. `python scripts/migrate-media.py` — انتقال یکباره تصاویر CDN خارجی به MinIO
|
||||
6. پشتیبانگیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
|
||||
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
|
||||
7. یک تمرین بازگردانی: `bash scripts/restore.sh backups/db-<زمان>.sql.gz` روی محیط آزمایشی
|
||||
8. مانیتورینگ (بخش پایین) و سپس چتبات/آنالیتیکس را با متغیرهای LLM و Umami فعال کنید
|
||||
|
||||
## مانیتورینگ (Prometheus + Grafana + Loki)
|
||||
|
||||
```bash
|
||||
bash scripts/stack.sh monitoring
|
||||
# در تولید (سه فایل با هم):
|
||||
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
- داشبورد: `grafana.localhost` (توسعه) یا `grafana.example.com` (تولید — برچسبهای
|
||||
روتر آن را در `docker-compose.override.yml` سرور بگذارید؛ نمونه در انتهای
|
||||
`docker-compose.prod.yml`) — کاربر/رمز از `GRAFANA_ADMIN_*` در `.env`
|
||||
- دو داشبورد آمادهٔ «زیرساخت» و «اپلیکیشن» بهصورت code-provisioned سوار میشوند
|
||||
- **هشدارها به بله**: در `.env` مقدار `ALERT_BOT_TOKEN` (بات بله — همان شکل API تلگرام،
|
||||
از BotFather بله) و `ALERT_CHAT_ID` را بگذارید (شناسه چت: به بات یک پیام بدهید سپس
|
||||
`curl "https://tapi.bale.ai/bot<توکن>/getUpdates"`) — قواعد آماده در
|
||||
`monitoring/prometheus/alerts.yml`:
|
||||
پایینبودن هر سرویس، شکست بررسی عمق سلامت، پرشدن دیسک، نرخ خطای 5xx، انقضای گواهی TLS،
|
||||
**کهنهشدن پشتیبان** (سنجهٔ `backup_success_time` از `scripts/backup.sh`) و...
|
||||
- **لاگها در Grafana**: Promtail لاگ همهٔ کانتینرهای استک را به Loki میفرستد —
|
||||
برای خطاهای بکاند از داشبورد «اپلیکیشن» پنل لاگ را ببینید
|
||||
- **سلامت واقعی**: `/api/health` اتصال DB و MinIO را واقعاً میآزماید (503 در خرابی)؛
|
||||
پروب blackbox هم همین را از بیرون و نشانی `SITE_URL` را عمومی چک میکند
|
||||
- مصرف منابع مجموع لایه حدود ۱ تا ۱.۵ گیگابایت RAM است؛ حذف هر سرویس بقیه را نمیشکند
|
||||
|
||||
## آنالیتیکس محصول (Umami)
|
||||
|
||||
استک شامل سرویس Umami است؛ داشبورد: `analytics.localhost` (توسعه) یا
|
||||
`analytics.example.com` (تولید). بدون کوکی — بدون نیاز به بنر رضایت.
|
||||
|
||||
راهاندازی (یکبار):
|
||||
|
||||
1. استک را بالا بیاورید و به `analytics.localhost` وارد شوید (پیشفرض `admin`/`umami`
|
||||
— بلافاصله رمز را عوض کنید)
|
||||
2. یک وبسایت با دامنهٔ سایت بسازید؛ «شناسه وبسایت» را در `.env` بگذارید:
|
||||
`VITE_UMAMI_WEBSITE_ID=...` و آدرس اسکریپت: `VITE_UMAMI_SRC=https://analytics.example.com/script.js`
|
||||
3. `bash scripts/stack.sh deploy` — اسکریپت ردیاب در `index.html` تزریق میشود
|
||||
|
||||
رخدادهای سفارشی که خودکار ارسال میشوند: ارسال فرم تماس (`contact-submit`)، عضویت
|
||||
خبرنامه (`newsletter-signup`)، کلیک تلفن (`phone-click`)، باز کردن چتبات
|
||||
(`chat-open`)، پرسش از چتبات (`chat-question`)، تغییر زبان (`lang-switch`) —
|
||||
سرچشمه: `frontend/src/analytics.ts`.
|
||||
|
||||
## چتبات (دستیار هوشمند)
|
||||
|
||||
- RAG روی محتوای همان دیتابیس سایت: امبدینگ MiniLM چندزبانه + BM25 + رنکر — بردارها
|
||||
در دیتابیس جدا `aramesh_chatbot` (pgvector) روی همان نمونهٔ Postgres
|
||||
- مسیرها: `GET /chat/healthz`، `GET /chat/suggestions`، `GET /chat/stream` (SSE) —
|
||||
رابط کاربری: ویجت شناور پایین صفحه (`ChatWidget.tsx`)
|
||||
- راهاندازی: `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` در `.env` (هر LLM سازگار با
|
||||
OpenAI) — بدون آن سرویس بالا میآید اما پاسخ نمیدهد
|
||||
- اولین اجرا مدلها را دانلود میکند (~۵۰۰MB در حجم `chatbot_hf`) — کند است، طبیعی است
|
||||
- ایندکس هر ۱۰ دقیقه خودش با تغییرهای محتوا همگام میشود؛ همگامسازی فوری با
|
||||
`POST /chat/reindex` و هدر `x-reindex-token` (از `CHAT_REINDEX_TOKEN`)
|
||||
|
||||
## امکانات بکاند
|
||||
|
||||
- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
|
||||
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
|
||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
|
||||
و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` — هر دو با
|
||||
محدودسازی نرخ (تماس: ۵ در ۱۵ دقیقه، خبرنامه: ۳ در ساعت برای هر IP) + دامنهٔ پنهان
|
||||
(honeypot) ضدربات + میانافزار محدودسازی سیلاب روی Traefik
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (مقادیر از `ADMIN_*` در `.env` —
|
||||
هیچ رمز پیشفرضی در کد وجود ندارد) و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
|
||||
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
|
||||
(IP واقعی کلاینت با `--proxy-headers` از پشت Traefik درست خوانده میشود)
|
||||
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
|
||||
ذخیره و URL نسبی `/media/...` برمیگردد
|
||||
ذخیره و URL نسبی `/media/...` برمیگردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
|
||||
فشرده میشود (Pillow) تا سرعت صفحه حفظ شود
|
||||
- **خروجی CSV** از نوار مدیریت: پیامهای تماس و مشترکان خبرنامه (با BOM برای Excel)
|
||||
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
|
||||
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
|
||||
- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
|
||||
- **تستها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
|
||||
- **مهاجرتهای اسکیما**: Alembic (`backend/migrations/`) — هر تغییر اسکیمای جدید باید
|
||||
revision شود: `cd backend && alembic revision --autogenerate -m "..."` و پس از بازبینی
|
||||
commit؛ در استارت کانتینر خودکار `upgrade head` اجرا میشود
|
||||
- **تستها**: `cd backend && .venv/Scripts/activate && pytest tests` (۲۴ تست — شامل
|
||||
honeypot، محدودسازی نرخ و پاکسازی)
|
||||
|
||||
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید
|
||||
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
|
||||
> بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||
|
||||
## ویرایش زنده سایت (دوقلوی دیجیتال)
|
||||
|
||||
@@ -157,7 +268,9 @@ npm run dev
|
||||
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
|
||||
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل میکند، پس فقط تگهای شناختهشده
|
||||
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
|
||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند. در زمان ذخیره، آدرسهای
|
||||
`href/src` سند در سمت سرور پاکسازی میشوند (`app/sanitize.py`) تا اسکیمهای خطرناک
|
||||
مثل `javascript:` هرگز ذخیره نشوند.
|
||||
|
||||
## سئو
|
||||
|
||||
@@ -179,24 +292,39 @@ npm run dev
|
||||
دیتابیس داخل آن تزریق میکند (`app/seo_pages.py`) — بازدیدکنندههای عادی همان SPA
|
||||
همیشگی را میبینند
|
||||
|
||||
## پشتیبانگیری
|
||||
## پشتیبانگیری و بازگردانی
|
||||
|
||||
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
|
||||
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است:
|
||||
|
||||
```bash
|
||||
bash scripts/backup.sh # در backups/ — نگهداری پیشفرض ۱۴ روز
|
||||
bash scripts/stack.sh backup # پشتیبان کامل: DB + تصاویر MinIO
|
||||
docker compose --profile ops up -d db-backup # پشتیبان ساعتی خودکار DB (بدون cron میزبان)
|
||||
```
|
||||
|
||||
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
|
||||
- زمانبندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
|
||||
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
|
||||
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده + فایل `.sha256`) و `backups/images-<زمان>/`
|
||||
- اسکریپت پشتیبان، خروجی خالی/شکسته را خطا میگیرد، سنجهٔ تازگی برای هشدار Grafana
|
||||
مینویسد و اگر `OFFSITE_RCLONE_REMOTE` در `.env` تنظیم باشد، پشتیبان را به حافظهٔ
|
||||
دیگری هم کپی میکند (rclone)
|
||||
- **بازگردانی**: `bash scripts/stack.sh restore backups/db-<زمان>.sql.gz`
|
||||
(احتیاط: محتوای فعلی جایگزین میشود — قبل از انتشار واقعی یکبار تمرین کنید!)
|
||||
- زمانبندی پشتیبان کامل با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
|
||||
|
||||
## تست و CI
|
||||
|
||||
- `bash scripts/ci.sh` — تستهای pytest + بیلد ایمیجها + ممیزی وابستگیها
|
||||
- `bash scripts/audit.sh` — pip-audit / npm audit / trivy (هر کدام نصب بود)
|
||||
- `.github/workflows/ci.yml` — همین مراحل؛ سینتکس GitHub Actions روی Gitea Actions/
|
||||
Forgejo هم اجرا میشود
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
|
||||
با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
|
||||
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
|
||||
`${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید؛ هیچ رمز پیشفرضی در کد نیست
|
||||
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
||||
- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
|
||||
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
||||
- توکنهای JWT ۸ ساعت اعتبار دارند (stateless — تغییر `SECRET_KEY` همه را خارج میکند)
|
||||
- همهٔ کانتینرها **بدون root** اجرا میشوند؛ کانتکست بیلد هر ایمیج با `.dockerignore`
|
||||
باریک است و لاگها سقف دارند
|
||||
- فرمهای عمومی ضدرباتاند (honeypot + محدودسازی نرخ در بکاند + سقف سیلاب در Traefik)
|
||||
- کنسول MinIO و API مستقیمش در تولید فقط از `127.0.0.1` در دسترساند (تونل SSH)
|
||||
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
|
||||
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# کانتکست بیلد باریک — .venv و کشهای تست محلی جایی در تصویر ندارند
|
||||
.venv/
|
||||
venv/
|
||||
tests/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
+12
-3
@@ -1,3 +1,4 @@
|
||||
# بکاند FastAPI — تصویر باریک و بدون root
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -9,8 +10,16 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY alembic.ini ./
|
||||
COPY migrations ./migrations
|
||||
|
||||
# کاربر بدون دسترسی root — برنامه چیزی روی دیسک محلی نمینویسد
|
||||
RUN useradd --system --create-home appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# ساخت جداول و seed (بدون حذف دادههای موجود) و سپس اجرای سرور
|
||||
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
# ساخت جداول پایه، مهاجرتهای Alembic و seed (بدون حذف دادههای موجود) و سپس
|
||||
# اجرای سرور با exec — تا SIGTERM (docker stop) مستقیم به uvicorn برسد و خاموشی
|
||||
# تمیز انجام شود؛ --proxy-headers برای دیدن IP واقعی کلاینت پشت Traefik
|
||||
CMD ["sh", "-c", "python -m app.db_init && alembic upgrade head && python -m app.seed && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'"]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
|
||||
# آدرس دیتابیس از DATABASE_URL (محیط) یا تنظیمات برنامه خوانده میشود — env.py
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
+198
-16
@@ -1,10 +1,19 @@
|
||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||
|
||||
import csv
|
||||
import ipaddress
|
||||
import io
|
||||
import re
|
||||
import socket
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from PIL import Image, ImageOps
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -263,10 +272,69 @@ ALLOWED_IMAGE_TYPES = {
|
||||
"image/avif": "avif",
|
||||
}
|
||||
|
||||
# بهینهسازی در زمان آپلود — ابعاد بزرگ و فشردهسازی ضعیف، اصلیترین عامل کندی سایت است
|
||||
MAX_DIMENSION = 2000
|
||||
REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
|
||||
|
||||
def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]:
|
||||
"""تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده میکند؛
|
||||
اگر خروجی بزرگتر از اصلی شد، همان اصلی برگردد. (gif/avif دستنخورده)"""
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = ImageOps.exif_transpose(img)
|
||||
orig_w, orig_h = img.size
|
||||
if max(img.size) > MAX_DIMENSION:
|
||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
if content_type == "image/jpeg":
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
img.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
|
||||
elif content_type == "image/png":
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
else:
|
||||
img.save(buf, "WEBP", quality=82, method=4)
|
||||
optimized = buf.getvalue()
|
||||
if len(optimized) < len(data):
|
||||
return optimized, content_type, *img.size
|
||||
return data, content_type, orig_w, orig_h
|
||||
|
||||
|
||||
def _readable_key(name: str, ext: str) -> str:
|
||||
"""کلید ذخیرهسازی خوانا از نام دلخواه/نام فایل — «۱۴۰۳/elahieh-tower-a1b2c3.jpg».
|
||||
|
||||
نامگذاریها ایمن میشوند: نویسههای ناامن و جداکننده مسیر حذف، فاصلهها خط تیره،
|
||||
طول محدود؛ خالی بودن نام → شناسه تصادفی مثل قبل. پسوند ۶ نویسهای تضمین یکتایی است.
|
||||
"""
|
||||
slug = re.sub(r"[^\w-ۿ\- ]+", "", name, flags=re.UNICODE).strip()
|
||||
slug = re.sub(r"[\s_]+", "-", slug).strip("-")[:80]
|
||||
unique = uuid.uuid4().hex[:6]
|
||||
base = f"{slug}-{unique}" if slug else uuid.uuid4().hex
|
||||
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
|
||||
|
||||
|
||||
def _store_image(data: bytes, content_type: str, name: str) -> str:
|
||||
"""ذخیره تصویر در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
||||
ext = ALLOWED_IMAGE_TYPES[content_type]
|
||||
key = _readable_key(name, ext)
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name="us-east-1",
|
||||
)
|
||||
try:
|
||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||
return f"{settings.media_public_base}/{key}"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(""),
|
||||
_admin: models.AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
content_type = (file.content_type or "").lower()
|
||||
@@ -287,29 +355,143 @@ async def upload_media(
|
||||
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
||||
width = height = None
|
||||
if content_type in REENCODE_TYPES:
|
||||
try:
|
||||
data, content_type, width, height = _optimize_image(data, content_type)
|
||||
except Exception:
|
||||
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
||||
pass
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name="us-east-1",
|
||||
)
|
||||
try:
|
||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
||||
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
||||
url = _store_image(data, content_type, display_name)
|
||||
|
||||
return {
|
||||
"url": f"{settings.media_public_base}/{key}",
|
||||
"key": key,
|
||||
"url": url,
|
||||
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
||||
"size": len(data),
|
||||
"content_type": content_type,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
# ---------- درونریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ----------
|
||||
|
||||
|
||||
class ImportImageIn(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
def _assert_public_http(url: str) -> None:
|
||||
"""فقط http/https به مقاصد عمومی — جلوگیری از SSRF به شبکه داخلی."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="نشانی تصویر معتبر نیست")
|
||||
try:
|
||||
infos = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror:
|
||||
raise HTTPException(status_code=400, detail="میزبان تصویر پیدا نشد")
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if not ip.is_global:
|
||||
raise HTTPException(status_code=400, detail="نشانی تصویر به میزبان داخلی اشاره میکند")
|
||||
|
||||
|
||||
@router.post("/import-image")
|
||||
async def import_image(
|
||||
payload: ImportImageIn,
|
||||
_admin: models.AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
"""دانلود تصویر از نشانی خارجی، بهینهسازی و ذخیره در Object Storage خودِ سایت —
|
||||
تصاویر چسباندهشده از Outline/مستندات خارجی به میزبانی سایت منتقل میشوند."""
|
||||
_assert_public_http(payload.url)
|
||||
|
||||
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
||||
request = urllib.request.Request(
|
||||
payload.url, headers={"User-Agent": "ArameshSite/1.0 (+image import)"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
||||
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="فرمت تصویر پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
||||
)
|
||||
data = response.read(max_bytes + 1)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
|
||||
if len(data) == 0:
|
||||
raise HTTPException(status_code=400, detail="تصویر خالی است")
|
||||
if len(data) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
width = height = None
|
||||
if content_type in REENCODE_TYPES:
|
||||
try:
|
||||
data, content_type, width, height = _optimize_image(data, content_type)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
||||
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
media_url = _store_image(data, content_type, url_name)
|
||||
|
||||
return {
|
||||
"url": media_url,
|
||||
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
||||
"size": len(data),
|
||||
"content_type": content_type,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
# ---------- خروجی CSV (برای تیم فروش) ----------
|
||||
|
||||
def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response:
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return Response(
|
||||
# BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند
|
||||
content="\ufeff" + buf.getvalue(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/messages/export.csv")
|
||||
def export_messages(db: Session = Depends(get_db)):
|
||||
rows = [
|
||||
[m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()]
|
||||
for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all()
|
||||
]
|
||||
return _csv_response(
|
||||
"contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subscribers/export.csv")
|
||||
def export_subscribers(db: Session = Depends(get_db)):
|
||||
rows = [
|
||||
[s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()]
|
||||
for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
|
||||
]
|
||||
return _csv_response(
|
||||
"newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows
|
||||
)
|
||||
|
||||
|
||||
# پیامهای تماس
|
||||
@router.get("/messages", response_model=list[ContactMessageOut])
|
||||
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه."""
|
||||
"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه.
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
هر فرم یک دامنهٔ پنهان (honeypot) دارد: رباتها همهٔ فیلدها را پُر میکنند؛
|
||||
در آن حالت پاسخ موفقِ قلابی برمیگردد و چیزی ذخیره نمیشود.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
from ..database import get_db
|
||||
from ..rate_limit import contact_limiter, newsletter_limiter
|
||||
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _fake_contact(payload: ContactIn) -> ContactMessageOut:
|
||||
"""پاسخ موفق بدون ذخیره — تا ربات متوجه تله نشود."""
|
||||
return ContactMessageOut(
|
||||
id=0,
|
||||
name=payload.name.strip(),
|
||||
phone=payload.phone.strip(),
|
||||
note="",
|
||||
is_read=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _fake_subscriber(payload: NewsletterIn) -> SubscriberOut:
|
||||
return SubscriberOut(
|
||||
id=0,
|
||||
email=str(payload.email).lower(),
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/contact", response_model=ContactMessageOut, status_code=201)
|
||||
def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
|
||||
def submit_contact(payload: ContactIn, request: Request, db: Session = Depends(get_db)):
|
||||
contact_limiter.hit(request)
|
||||
if payload.website:
|
||||
return _fake_contact(payload)
|
||||
message = models.ContactMessage(
|
||||
name=payload.name.strip(),
|
||||
phone=payload.phone.strip(),
|
||||
@@ -24,7 +55,11 @@ def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/newsletter", response_model=SubscriberOut, status_code=201)
|
||||
def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)):
|
||||
def subscribe(payload: NewsletterIn, request: Request, db: Session = Depends(get_db)):
|
||||
newsletter_limiter.hit(request)
|
||||
if payload.website:
|
||||
return _fake_subscriber(payload)
|
||||
|
||||
email = str(payload.email).lower()
|
||||
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
from ..schemas import (
|
||||
@@ -22,9 +27,44 @@ from ..schemas import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
def live():
|
||||
"""زندهبودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمیشود."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "app": "Aramesh Building Group API"}
|
||||
def health(db: Session = Depends(get_db)):
|
||||
"""بررسی عمق سلامت — اتصال DB و MinIO واقعاً آزموده میشود؛
|
||||
در صورت خرابی هر وابستگی، 503 با جزئیات برمیگردد."""
|
||||
checks: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
checks["database"] = "ok"
|
||||
except Exception:
|
||||
checks["database"] = "error"
|
||||
|
||||
try:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name="us-east-1",
|
||||
config=BotoConfig(connect_timeout=2, read_timeout=2, retries={"max_attempts": 1}),
|
||||
)
|
||||
s3.head_bucket(Bucket=settings.s3_bucket)
|
||||
checks["storage"] = "ok"
|
||||
except Exception:
|
||||
checks["storage"] = "error"
|
||||
|
||||
if any(state != "ok" for state in checks.values()):
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "degraded", "checks": checks},
|
||||
)
|
||||
return {"status": "ok", "app": "Aramesh Building Group API", "checks": checks}
|
||||
|
||||
|
||||
@router.get("/brand", response_model=BrandOut)
|
||||
|
||||
@@ -13,7 +13,8 @@ class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
|
||||
secret_key: str = "dev-secret-change-me-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 1440
|
||||
# ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاهتر ریسک نشت را کم میکند
|
||||
access_token_expire_minutes: int = 480
|
||||
cors_origins: list[str] = ["*"]
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "aramesh123"
|
||||
@@ -31,6 +32,10 @@ class Settings(BaseSettings):
|
||||
site_url: str = ""
|
||||
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||
frontend_dist: str = "../frontend/dist"
|
||||
# پیشنمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکسشدنی.
|
||||
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
|
||||
# هدر noindex روی همه پاسخها + robots.txt مسدودکننده.
|
||||
preview_mode: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""ساخت جداول پایه (baseline) — پیش از مهاجرتهای Alembic.
|
||||
|
||||
روی دیتابیس تازه، همهٔ جدولها از مدلهای فعلی ساخته میشوند؛ روی دیتابیس موجود
|
||||
بیاثر است (create_all جدولهای موجود را دست نمیزند). از اینجا به بعد تغییرات
|
||||
اسکیما فقط با revisionهای Alembic اعمال میشوند.
|
||||
"""
|
||||
|
||||
from . import models # noqa: F401 — ثبت مدلها
|
||||
from .database import Base, engine
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
+23
-2
@@ -1,15 +1,17 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
|
||||
from .api import admin, forms, public
|
||||
from .config import settings
|
||||
from .database import Base, engine
|
||||
from .migrations import run_all as run_migrations
|
||||
from .seo_pages import router as seo_router
|
||||
|
||||
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال میشوند
|
||||
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
|
||||
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام میدهد.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
app = FastAPI(
|
||||
title="Aramesh Building Group API",
|
||||
@@ -29,6 +31,19 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
|
||||
|
||||
# پیشنمایش خصوصی — لایه دوم جلوگیری از ایندکس (لایه اول، گیت ورود Traefik است).
|
||||
# روی همه پاسخها هدر noindex مینشیند تا حتی در صورت حذف گیت، گوگل چیزی ذخیره نکند.
|
||||
# همیشه ثبت میشود و پرچم در زمان هر درخواست خوانده میشود (خاموشکردن انتشار = فقط تغییر .env).
|
||||
@app.middleware("http")
|
||||
async def _preview_noindex(request, call_next):
|
||||
response = await call_next(request)
|
||||
if settings.preview_mode:
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||
return response
|
||||
|
||||
app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
||||
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
||||
@@ -37,6 +52,12 @@ app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت
|
||||
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
|
||||
app.include_router(seo_router, tags=["سئو"])
|
||||
|
||||
# متریکهای Prometheus — فقط Prometheus در شبکه داخلی اسکرپ میکند
|
||||
# (Traefik مسیر /metrics را نمیشناسد، پس از بیرون در دسترس نیست)
|
||||
from prometheus_fastapi_instrumentator import Instrumentator # noqa: E402
|
||||
|
||||
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
|
||||
@@ -15,6 +15,119 @@ POSITION_TABLES = [
|
||||
"offices", "gallery_images", "nav_items",
|
||||
]
|
||||
|
||||
# ستونهای انگلیسی — (جدول، ستون، نوع). ستونهای JSON بدون default اند تا
|
||||
# سطرهای موجود NULL بگیرند (یعنی «ترجمه ثبت نشده») و فرانت به فارسی برگردد.
|
||||
EN_COLUMNS: list[tuple[str, str, str]] = [
|
||||
("brand", "name_en", "VARCHAR(128)"),
|
||||
("brand", "address_en", "TEXT"),
|
||||
("services", "title_en", "VARCHAR(256)"),
|
||||
("services", "desc_en", "TEXT"),
|
||||
("projects", "title_en", "VARCHAR(256)"),
|
||||
("projects", "location_en", "VARCHAR(128)"),
|
||||
("projects", "year_en", "VARCHAR(64)"),
|
||||
("projects", "area_en", "VARCHAR(64)"),
|
||||
("projects", "floors_en", "VARCHAR(64)"),
|
||||
("projects", "units_en", "VARCHAR(64)"),
|
||||
("projects", "client_en", "VARCHAR(128)"),
|
||||
("projects", "summary_en", "TEXT"),
|
||||
("projects", "body_en", "JSON"),
|
||||
("articles", "title_en", "VARCHAR(256)"),
|
||||
("articles", "date_en", "VARCHAR(64)"),
|
||||
("articles", "category_en", "VARCHAR(64)"),
|
||||
("articles", "read_en", "VARCHAR(32)"),
|
||||
("articles", "excerpt_en", "TEXT"),
|
||||
("articles", "body_en", "JSON"),
|
||||
("testimonials", "name_en", "VARCHAR(128)"),
|
||||
("testimonials", "role_en", "VARCHAR(128)"),
|
||||
("testimonials", "quote_en", "TEXT"),
|
||||
("faqs", "question_en", "TEXT"),
|
||||
("faqs", "answer_en", "TEXT"),
|
||||
("team", "name_en", "VARCHAR(128)"),
|
||||
("team", "role_en", "VARCHAR(128)"),
|
||||
("team", "bio_en", "TEXT"),
|
||||
("timeline", "year_en", "VARCHAR(32)"),
|
||||
("timeline", "title_en", "VARCHAR(256)"),
|
||||
("timeline", "desc_en", "TEXT"),
|
||||
("stats", "suffix_en", "VARCHAR(16)"),
|
||||
("stats", "label_en", "VARCHAR(128)"),
|
||||
("why_us", "title_en", "VARCHAR(256)"),
|
||||
("why_us", "desc_en", "TEXT"),
|
||||
("before_after", "title_en", "VARCHAR(256)"),
|
||||
("before_after", "lead_en", "TEXT"),
|
||||
("before_after", "before_label_en", "VARCHAR(128)"),
|
||||
("before_after", "after_label_en", "VARCHAR(128)"),
|
||||
("nav_items", "label_en", "VARCHAR(64)"),
|
||||
("hero", "kicker_en", "TEXT"),
|
||||
("hero", "title_en", "TEXT"),
|
||||
("hero", "title_highlight_en", "VARCHAR(128)"),
|
||||
("hero", "subtitle_en", "TEXT"),
|
||||
("hero", "badge_value_en", "VARCHAR(32)"),
|
||||
("hero", "badge_label_en", "TEXT"),
|
||||
("hero", "cta_primary_label_en", "VARCHAR(128)"),
|
||||
("hero", "cta_secondary_label_en", "VARCHAR(128)"),
|
||||
("hero", "scroll_label_en", "VARCHAR(64)"),
|
||||
("story", "title_en", "VARCHAR(256)"),
|
||||
("story", "paragraphs_en", "JSON"),
|
||||
("story", "quote_en", "TEXT"),
|
||||
("story", "quote_author_en", "TEXT"),
|
||||
("story", "stat1_value_en", "VARCHAR(32)"),
|
||||
("story", "stat1_label_en", "VARCHAR(128)"),
|
||||
("story", "stat2_value_en", "VARCHAR(32)"),
|
||||
("story", "stat2_label_en", "VARCHAR(128)"),
|
||||
("story", "link_label_en", "VARCHAR(128)"),
|
||||
("story", "bento_en", "JSON"),
|
||||
("section_headings", "label_en", "VARCHAR(64)"),
|
||||
("section_headings", "title_en", "VARCHAR(256)"),
|
||||
("section_headings", "lead_en", "TEXT"),
|
||||
("section_headings", "cta_label_en", "VARCHAR(128)"),
|
||||
("page_heroes", "title_en", "VARCHAR(256)"),
|
||||
("page_heroes", "lead_en", "TEXT"),
|
||||
("investment_models", "title_en", "VARCHAR(256)"),
|
||||
("investment_models", "desc_en", "TEXT"),
|
||||
("investment_models", "points_en", "JSON"),
|
||||
("process_steps", "step_en", "VARCHAR(16)"),
|
||||
("process_steps", "title_en", "VARCHAR(256)"),
|
||||
("process_steps", "desc_en", "TEXT"),
|
||||
("offices", "city_en", "VARCHAR(64)"),
|
||||
("offices", "address_en", "TEXT"),
|
||||
("offices", "hours_en", "VARCHAR(128)"),
|
||||
("gallery_images", "alt_en", "TEXT"),
|
||||
("status_labels", "label_en", "VARCHAR(64)"),
|
||||
("contact_section", "label_en", "VARCHAR(64)"),
|
||||
("contact_section", "title_en", "VARCHAR(256)"),
|
||||
("contact_section", "lead_en", "TEXT"),
|
||||
("contact_section", "address_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "phone_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "email_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "name_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "name_placeholder_en", "VARCHAR(128)"),
|
||||
("contact_section", "phone_field_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "phone_placeholder_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_optional_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "note_placeholder_en", "VARCHAR(256)"),
|
||||
("contact_section", "submit_label_en", "VARCHAR(128)"),
|
||||
("contact_section", "sending_label_en", "VARCHAR(64)"),
|
||||
("contact_section", "success_title_en", "VARCHAR(256)"),
|
||||
("contact_section", "success_text_en", "TEXT"),
|
||||
("contact_section", "success_button_label_en", "VARCHAR(128)"),
|
||||
("newsletter", "title_en", "VARCHAR(256)"),
|
||||
("newsletter", "lead_en", "TEXT"),
|
||||
("newsletter", "placeholder_en", "VARCHAR(128)"),
|
||||
("newsletter", "button_label_en", "VARCHAR(64)"),
|
||||
("newsletter", "sending_label_en", "VARCHAR(64)"),
|
||||
("newsletter", "success_text_en", "VARCHAR(256)"),
|
||||
("footer", "about_text_en", "TEXT"),
|
||||
("footer", "pages_heading_en", "VARCHAR(64)"),
|
||||
("footer", "services_heading_en", "VARCHAR(64)"),
|
||||
("footer", "contact_heading_en", "VARCHAR(64)"),
|
||||
("footer", "copyright_en", "VARCHAR(256)"),
|
||||
("footer", "tagline_en", "VARCHAR(256)"),
|
||||
("footer", "socials_en", "JSON"),
|
||||
("seo_meta", "title_en", "VARCHAR(256)"),
|
||||
("seo_meta", "description_en", "TEXT"),
|
||||
]
|
||||
|
||||
|
||||
def run_ddl_migrations() -> None:
|
||||
"""ستونهای جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
|
||||
@@ -32,6 +145,25 @@ def run_ddl_migrations() -> None:
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||
)
|
||||
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
||||
)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_title VARCHAR(256) NOT NULL DEFAULT ''")
|
||||
)
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs JSON"))
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs_en JSON"))
|
||||
# سطرهای موجود ستون تازه را NULL میگیرند — آرایه خالی یعنی «بدون پرسشوپاسخ»
|
||||
conn.execute(text("UPDATE articles SET faqs = '[]'::json WHERE faqs IS NULL"))
|
||||
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
||||
for table, column, col_type in EN_COLUMNS:
|
||||
if col_type == "JSON":
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} JSON"))
|
||||
else:
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} {col_type} NOT NULL DEFAULT ''")
|
||||
)
|
||||
|
||||
|
||||
def paragraphs_to_doc(paragraphs: list) -> dict:
|
||||
@@ -66,6 +198,122 @@ def migrate_article_bodies() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def _is_empty(value: object) -> bool:
|
||||
"""مقدار خالی انگلیسی — یعنی هنوز ترجمهای ثبت نشده و قابل پر کردن است."""
|
||||
if value is None or value == "" or value == [] or value == {}:
|
||||
return True
|
||||
if isinstance(value, dict) and value.get("content") == [{"type": "paragraph"}]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _backfill_row(row, en_fields: dict) -> int:
|
||||
"""پر کردن فیلدهای خالی *_en یک سطر از دیکشنری ترجمه — تعداد فیلدهای پرشده."""
|
||||
filled = 0
|
||||
for key, value in en_fields.items():
|
||||
if not key.endswith("_en"):
|
||||
continue
|
||||
if not _is_empty(getattr(row, key, None)):
|
||||
continue
|
||||
setattr(row, key, value)
|
||||
filled += 1
|
||||
return filled
|
||||
|
||||
|
||||
def backfill_en_content() -> None:
|
||||
"""پر کردن ترجمههای انگلیسی سطرهای موجود از seed_data_en — idempotent.
|
||||
|
||||
تطبیق سطرها: جدولهای تکسطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی
|
||||
(مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را
|
||||
ویرایش کرده باشد سطر تطبیق نمیخورد و ترجمهاش خالی میماند تا از پنل پر شود.
|
||||
هیچ مقدار انگلیسیِ ثبتشده بازنویسی نمیشود.
|
||||
"""
|
||||
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقهای
|
||||
|
||||
# (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تکسطریها)
|
||||
PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [
|
||||
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()),
|
||||
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()),
|
||||
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()),
|
||||
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, ("key", "title")),
|
||||
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")),
|
||||
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")),
|
||||
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)),
|
||||
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)),
|
||||
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)),
|
||||
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, ("alt",)),
|
||||
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)),
|
||||
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()),
|
||||
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()),
|
||||
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()),
|
||||
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)),
|
||||
(models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)),
|
||||
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")),
|
||||
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")),
|
||||
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)),
|
||||
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)),
|
||||
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)),
|
||||
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)),
|
||||
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)),
|
||||
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()),
|
||||
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)),
|
||||
]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total_rows = 0
|
||||
total_fields = 0
|
||||
unmatched: list[str] = []
|
||||
for model, fa_rows, en_rows, match_keys in PLAN:
|
||||
rows = db.query(model).all()
|
||||
if not rows or not en_rows:
|
||||
continue
|
||||
table_name = getattr(model, "__tablename__", str(model))
|
||||
|
||||
if not match_keys:
|
||||
if len(rows) != 1 or len(en_rows) != 1:
|
||||
unmatched.append(table_name)
|
||||
continue
|
||||
pairs = [(rows[0], en_rows[0])]
|
||||
else:
|
||||
# برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است)
|
||||
en_lookups: list[dict[object, dict]] = []
|
||||
for key in match_keys:
|
||||
en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None})
|
||||
pairs = []
|
||||
matched_en_ids: set[int] = set()
|
||||
for row in rows:
|
||||
en_row = None
|
||||
for key, lookup in zip(match_keys, en_lookups):
|
||||
en_row = lookup.get(getattr(row, key, None))
|
||||
if en_row is not None:
|
||||
break
|
||||
if en_row is not None and id(en_row) not in matched_en_ids:
|
||||
matched_en_ids.add(id(en_row))
|
||||
pairs.append((row, en_row))
|
||||
|
||||
for row, en_row in pairs:
|
||||
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل میشود
|
||||
en_fields = dict(en_row)
|
||||
if model is models.Article and isinstance(en_fields.get("body_en"), list):
|
||||
en_fields["body_en"] = paragraphs_to_doc(en_fields["body_en"])
|
||||
filled = _backfill_row(row, en_fields)
|
||||
total_fields += filled
|
||||
if filled:
|
||||
total_rows += 1
|
||||
|
||||
db.commit()
|
||||
if total_fields:
|
||||
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
|
||||
if unmatched:
|
||||
print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):")
|
||||
for name in unmatched:
|
||||
print(f" - {name}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def run_all() -> None:
|
||||
run_ddl_migrations()
|
||||
migrate_article_bodies()
|
||||
backfill_en_content()
|
||||
|
||||
+120
-1
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -10,6 +11,10 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ستونهای *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
|
||||
# مقدار خالی یعنی «ترجمهای ثبت نشده» و فرانتاند به نسخه فارسی برمیگردد.
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
@@ -23,6 +28,7 @@ class Brand(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
logo: Mapped[str] = mapped_column(Text, default="")
|
||||
hero: Mapped[str] = mapped_column(Text, default="")
|
||||
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -30,6 +36,7 @@ class Brand(Base):
|
||||
phone_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
email: Mapped[str] = mapped_column(String(128), default="")
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Service(Base):
|
||||
@@ -39,7 +46,9 @@ class Service(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
icon: Mapped[str] = mapped_column(String(32), default="draft")
|
||||
|
||||
|
||||
@@ -51,17 +60,26 @@ class Project(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
location: Mapped[str] = mapped_column(String(128), default="")
|
||||
location_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
year: Mapped[str] = mapped_column(String(64), default="")
|
||||
year_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
status: Mapped[str] = mapped_column(String(16), default="done") # done | progress
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
gallery: Mapped[list] = mapped_column(JSON, default=list)
|
||||
area: Mapped[str] = mapped_column(String(64), default="")
|
||||
area_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
units: Mapped[str] = mapped_column(String(64), default="")
|
||||
units_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
client: Mapped[str] = mapped_column(String(128), default="")
|
||||
client_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
summary_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
body_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class Article(Base):
|
||||
@@ -72,12 +90,24 @@ class Article(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
date: Mapped[str] = mapped_column(String(64), default="")
|
||||
date_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
category: Mapped[str] = mapped_column(String(64), default="")
|
||||
category_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
# متن جایگزین و عنوان تصویر شاخص — سئو و دسترسپذیری
|
||||
image_alt: Mapped[str] = mapped_column(String(256), default="")
|
||||
image_title: Mapped[str] = mapped_column(String(256), default="")
|
||||
read: Mapped[str] = mapped_column(String(32), default="")
|
||||
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
||||
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
@@ -91,8 +121,11 @@ class Testimonial(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
role: Mapped[str] = mapped_column(String(128), default="")
|
||||
role_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
quote: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Faq(Base):
|
||||
@@ -102,7 +135,9 @@ class Faq(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
question: Mapped[str] = mapped_column(Text)
|
||||
question_en: Mapped[str] = mapped_column(Text, default="")
|
||||
answer: Mapped[str] = mapped_column(Text, default="")
|
||||
answer_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class TeamMember(Base):
|
||||
@@ -112,8 +147,11 @@ class TeamMember(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
role: Mapped[str] = mapped_column(String(128), default="")
|
||||
role_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
bio: Mapped[str] = mapped_column(Text, default="")
|
||||
bio_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class TimelineItem(Base):
|
||||
@@ -123,8 +161,11 @@ class TimelineItem(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
year: Mapped[str] = mapped_column(String(32))
|
||||
year_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
@@ -136,7 +177,9 @@ class Stat(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
value: Mapped[int] = mapped_column(Integer)
|
||||
suffix: Mapped[str] = mapped_column(String(16), default="")
|
||||
suffix_en: Mapped[str] = mapped_column(String(16), default="")
|
||||
label: Mapped[str] = mapped_column(String(128))
|
||||
label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class WhyUsItem(Base):
|
||||
@@ -146,7 +189,9 @@ class WhyUsItem(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class BeforeAfter(Base):
|
||||
@@ -154,11 +199,15 @@ class BeforeAfter(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
before: Mapped[str] = mapped_column(Text, default="")
|
||||
after: Mapped[str] = mapped_column(Text, default="")
|
||||
before_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
before_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
after_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
after_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class NavItem(Base):
|
||||
@@ -168,6 +217,7 @@ class NavItem(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
href: Mapped[str] = mapped_column(String(128))
|
||||
|
||||
|
||||
@@ -178,16 +228,25 @@ class Hero(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kicker: Mapped[str] = mapped_column(Text, default="")
|
||||
kicker_en: Mapped[str] = mapped_column(Text, default="")
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
title_en: Mapped[str] = mapped_column(Text, default="")
|
||||
title_highlight: Mapped[str] = mapped_column(String(128), default="")
|
||||
title_highlight_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
subtitle: Mapped[str] = mapped_column(Text, default="")
|
||||
subtitle_en: Mapped[str] = mapped_column(Text, default="")
|
||||
badge_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
badge_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
badge_label: Mapped[str] = mapped_column(Text, default="")
|
||||
badge_label_en: Mapped[str] = mapped_column(Text, default="")
|
||||
cta_primary_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_primary_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_primary_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_secondary_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
scroll_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
scroll_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class StorySection(Base):
|
||||
@@ -197,16 +256,26 @@ class StorySection(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
paragraphs: Mapped[list] = mapped_column(JSON, default=list)
|
||||
paragraphs_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
quote: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_author: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_author_en: Mapped[str] = mapped_column(Text, default="")
|
||||
stat1_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat1_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat1_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat1_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat2_value: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat2_value_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
stat2_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
stat2_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
link_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
|
||||
bento_en: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] انگلیسی
|
||||
|
||||
|
||||
class SectionHeadingContent(Base):
|
||||
@@ -217,10 +286,14 @@ class SectionHeadingContent(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(64), default="")
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
cta_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
cta_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
@@ -232,7 +305,9 @@ class PageHeroContent(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
@@ -243,8 +318,11 @@ class InvestmentModel(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
points: Mapped[list] = mapped_column(JSON, default=list)
|
||||
points_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class ProcessStep(Base):
|
||||
@@ -254,8 +332,11 @@ class ProcessStep(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
step: Mapped[str] = mapped_column(String(16), default="")
|
||||
step_en: Mapped[str] = mapped_column(String(16), default="")
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Office(Base):
|
||||
@@ -265,8 +346,11 @@ class Office(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
city: Mapped[str] = mapped_column(String(64))
|
||||
city_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||
hours: Mapped[str] = mapped_column(String(128), default="")
|
||||
hours_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class GalleryImage(Base):
|
||||
@@ -277,6 +361,7 @@ class GalleryImage(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
src: Mapped[str] = mapped_column(Text, default="")
|
||||
alt: Mapped[str] = mapped_column(Text, default="")
|
||||
alt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class StatusLabel(Base):
|
||||
@@ -287,6 +372,7 @@ class StatusLabel(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class ContactSectionContent(Base):
|
||||
@@ -296,23 +382,41 @@ class ContactSectionContent(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(64), default="")
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
address_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
address_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
email_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
email_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
name_placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||
name_placeholder_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
phone_field_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_field_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_placeholder_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_optional_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_optional_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
note_placeholder: Mapped[str] = mapped_column(String(256), default="")
|
||||
note_placeholder_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
submit_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
submit_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
success_title: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_text: Mapped[str] = mapped_column(Text, default="")
|
||||
success_text_en: Mapped[str] = mapped_column(Text, default="")
|
||||
success_button_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
success_button_label_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class NewsletterSectionContent(Base):
|
||||
@@ -322,11 +426,17 @@ class NewsletterSectionContent(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
placeholder: Mapped[str] = mapped_column(String(128), default="")
|
||||
placeholder_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
button_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
button_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label: Mapped[str] = mapped_column(String(64), default="")
|
||||
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
success_text: Mapped[str] = mapped_column(String(256), default="")
|
||||
success_text_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
|
||||
|
||||
class FooterContent(Base):
|
||||
@@ -336,12 +446,19 @@ class FooterContent(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
about_text: Mapped[str] = mapped_column(Text, default="")
|
||||
about_text_en: Mapped[str] = mapped_column(Text, default="")
|
||||
pages_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
pages_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
services_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
services_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
contact_heading: Mapped[str] = mapped_column(String(64), default="")
|
||||
contact_heading_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
copyright: Mapped[str] = mapped_column(String(256), default="")
|
||||
copyright_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
tagline: Mapped[str] = mapped_column(String(256), default="")
|
||||
tagline_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
|
||||
socials_en: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] انگلیسی
|
||||
|
||||
|
||||
class SeoMeta(Base):
|
||||
@@ -352,7 +469,9 @@ class SeoMeta(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
description_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class ContactMessage(Base):
|
||||
|
||||
+66
-15
@@ -1,38 +1,74 @@
|
||||
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
|
||||
"""محدودسازی نرخ درخواست — پنجرهٔ لغزان درونحافظهای.
|
||||
|
||||
پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
|
||||
شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
|
||||
برای ورود ادمین (بر پایه تلاش ناموفق) و فرمهای عمومی (بر پایه هر ارسال)
|
||||
استفاده میشود. شمارندهها در حافظهٔ همین پروسهاند — با ریاستارت پاک میشوند؛
|
||||
به همین دلیل uvicorn تککارگر نگه داشته شده است.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
WINDOW_SECONDS = 15 * 60
|
||||
|
||||
_failed: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""IP واقعی کلاینت — پشت Traefik از X-Forwarded-For خوانده میشود
|
||||
(uvicorn با --proxy-headers هم client.host را درست میکند)."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _prune(key: str, now: float) -> list[float]:
|
||||
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
|
||||
_failed[key] = recent
|
||||
return recent
|
||||
class RateLimiter:
|
||||
"""پنجرهٔ لغزان بر پایه کلید (عملاً IP) — رویدادها پس از پنجره منقضی میشوند."""
|
||||
|
||||
def __init__(self, max_events: int, window_seconds: int, message: str):
|
||||
self.max_events = max_events
|
||||
self.window_seconds = window_seconds
|
||||
self.message = message
|
||||
self._events: dict[str, list[float]] = {}
|
||||
|
||||
def _recent(self, key: str, now: float) -> list[float]:
|
||||
recent = [t for t in self._events.get(key, []) if now - t < self.window_seconds]
|
||||
self._events[key] = recent
|
||||
return recent
|
||||
|
||||
def _reject(self, recent: list[float]) -> None:
|
||||
now = time.monotonic()
|
||||
retry_after = max(1, int(self.window_seconds - (now - recent[0])))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=self.message,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
def hit(self, request: Request) -> None:
|
||||
"""یک رویداد ثبت میکند؛ اگر سهمیه کلید پر باشد 429 برمیگرداند."""
|
||||
key = client_ip(request)
|
||||
now = time.monotonic()
|
||||
recent = self._recent(key, now)
|
||||
if len(recent) >= self.max_events:
|
||||
self._reject(recent)
|
||||
self._events.setdefault(key, []).append(now)
|
||||
|
||||
def clear(self, request: Request) -> None:
|
||||
self._events.pop(client_ip(request), None)
|
||||
|
||||
|
||||
# ورود ادمین: ۵ تلاش ناموفق در ۱۵ دقیقه — فقط شکستها شمرده میشوند
|
||||
_failed: dict[str, list[float]] = {}
|
||||
LOGIN_MAX_FAILED_ATTEMPTS = 5
|
||||
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||
|
||||
|
||||
def ensure_allowed(request: Request) -> None:
|
||||
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
|
||||
key = client_ip(request)
|
||||
recent = _prune(key, time.monotonic())
|
||||
if len(recent) >= MAX_FAILED_ATTEMPTS:
|
||||
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
|
||||
now = time.monotonic()
|
||||
recent = [t for t in _failed.get(key, []) if now - t < LOGIN_WINDOW_SECONDS]
|
||||
_failed[key] = recent
|
||||
if len(recent) >= LOGIN_MAX_FAILED_ATTEMPTS:
|
||||
retry_after = max(1, int(LOGIN_WINDOW_SECONDS - (now - recent[0])))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
|
||||
@@ -47,3 +83,18 @@ def record_failure(request: Request) -> None:
|
||||
def clear(request: Request) -> None:
|
||||
"""با ورود موفق، تاریخچه تلاشهای ناموفق IP پاک میشود."""
|
||||
_failed.pop(client_ip(request), None)
|
||||
|
||||
|
||||
# فرم تماس: ۵ ارسال در ۱۵ دقیقه برای هر IP
|
||||
contact_limiter = RateLimiter(
|
||||
max_events=5,
|
||||
window_seconds=15 * 60,
|
||||
message="تعداد پیامهای ارسالی زیاد است — کمی بعد دوباره امتحان کنید",
|
||||
)
|
||||
|
||||
# خبرنامه: ۳ عضویت در ساعت برای هر IP
|
||||
newsletter_limiter = RateLimiter(
|
||||
max_events=3,
|
||||
window_seconds=60 * 60,
|
||||
message="تعداد درخواستها زیاد است — کمی بعد دوباره امتحان کنید",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""پاکسازی ورودیهای غنی در زمان ذخیره.
|
||||
|
||||
بدنه مقالهها سند TipTap (ProseMirror) است و فرانتاند همان را با
|
||||
renderToHTMLString به HTML تبدیل و مستقیم تزریق میکند؛ متن گرهها در آن
|
||||
مسیر escape میشود، اما آدرسهای href/src عیناً وارد HTML میشوند — پس
|
||||
اسکیمهای خطرناک (javascript: و data: و ...) باید در سمت سرور حذف شوند.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# اسکیمهای مجاز برای href/src — مسیرهای نسبی و لنگر هم بیاسکیماند و میگذرند
|
||||
SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
|
||||
URL_ATTRS = ("href", "src")
|
||||
|
||||
|
||||
def _clean_url(value: Any) -> str | None:
|
||||
"""آدرس امن را برمیگرداند؛ در غیر این صورت None (مشخصه حذف میشود)."""
|
||||
if not isinstance(value, str):
|
||||
return value if value is not None else None
|
||||
stripped = value.strip().replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
if not stripped:
|
||||
return None
|
||||
parsed = urlparse(stripped)
|
||||
if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
|
||||
return None
|
||||
return stripped
|
||||
|
||||
|
||||
def _walk(node: Any) -> Any:
|
||||
if isinstance(node, list):
|
||||
return [_walk(item) for item in node]
|
||||
if not isinstance(node, dict):
|
||||
return node
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in node.items():
|
||||
if key == "attrs" and isinstance(value, dict):
|
||||
attrs = dict(value)
|
||||
for attr in URL_ATTRS:
|
||||
if attr in attrs:
|
||||
cleaned_url = _clean_url(attrs[attr])
|
||||
if cleaned_url is None:
|
||||
attrs.pop(attr, None)
|
||||
else:
|
||||
attrs[attr] = cleaned_url
|
||||
cleaned[key] = attrs
|
||||
else:
|
||||
cleaned[key] = _walk(value)
|
||||
return cleaned
|
||||
|
||||
|
||||
def sanitize_doc(doc: Any) -> Any:
|
||||
"""سند TipTap را درجا پاک میکند (سایر ساختارها دستنخورده برمیگردند)."""
|
||||
if not isinstance(doc, dict):
|
||||
return doc
|
||||
return _walk(doc)
|
||||
+251
-2
@@ -3,6 +3,8 @@ from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
|
||||
|
||||
from .sanitize import sanitize_doc
|
||||
|
||||
# ---------- پایه ----------
|
||||
|
||||
|
||||
@@ -10,12 +12,16 @@ class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# فیلدهای *_en معادل انگلیسیاند؛ خالی بودنشان یعنی «بدون ترجمه».
|
||||
|
||||
|
||||
# ---------- خروجیها ----------
|
||||
|
||||
|
||||
class BrandOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
logo: str
|
||||
hero: str
|
||||
contact_bg: str
|
||||
@@ -23,12 +29,15 @@ class BrandOut(ORMModel):
|
||||
phone_href: str
|
||||
email: str
|
||||
address: str
|
||||
address_en: str = ""
|
||||
|
||||
|
||||
class ServiceOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
icon: str
|
||||
|
||||
|
||||
@@ -36,29 +45,49 @@ class ProjectOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
location: str
|
||||
location_en: str = ""
|
||||
year: str
|
||||
year_en: str = ""
|
||||
status: str
|
||||
image: str
|
||||
gallery: list[str]
|
||||
area: str
|
||||
area_en: str = ""
|
||||
floors: str
|
||||
floors_en: str = ""
|
||||
units: str
|
||||
units_en: str = ""
|
||||
client: str
|
||||
client_en: str = ""
|
||||
summary: str
|
||||
summary_en: str = ""
|
||||
body: list[str]
|
||||
body_en: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ArticleOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
date: str
|
||||
date_en: str = ""
|
||||
category: str
|
||||
category_en: str = ""
|
||||
image: str
|
||||
image_alt: str = ""
|
||||
image_title: str = ""
|
||||
read: str
|
||||
read_en: str = ""
|
||||
excerpt: str
|
||||
excerpt_en: str = ""
|
||||
body: dict
|
||||
body_en: Optional[dict] = None
|
||||
# پرسشوپاسخهای پایان مقاله — [{question, answer}]؛ سطرهای قدیمی ممکن است NULL باشند
|
||||
faqs: Optional[list[dict]] = None
|
||||
faqs_en: Optional[list[dict]] = None
|
||||
status: str
|
||||
seo_keyphrase: str
|
||||
|
||||
@@ -66,28 +95,39 @@ class ArticleOut(ORMModel):
|
||||
class TestimonialOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
role: str
|
||||
role_en: str = ""
|
||||
quote: str
|
||||
quote_en: str = ""
|
||||
|
||||
|
||||
class FaqOut(ORMModel):
|
||||
id: int
|
||||
question: str
|
||||
question_en: str = ""
|
||||
answer: str
|
||||
answer_en: str = ""
|
||||
|
||||
|
||||
class TeamMemberOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
role: str
|
||||
role_en: str = ""
|
||||
bio: str
|
||||
bio_en: str = ""
|
||||
|
||||
|
||||
class TimelineItemOut(ORMModel):
|
||||
id: int
|
||||
year: str
|
||||
year_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
image: str
|
||||
|
||||
|
||||
@@ -95,69 +135,101 @@ class StatOut(ORMModel):
|
||||
id: int
|
||||
value: int
|
||||
suffix: str
|
||||
suffix_en: str = ""
|
||||
label: str
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class WhyUsItemOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class BeforeAfterOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
before: str
|
||||
after: str
|
||||
before_label: str
|
||||
before_label_en: str = ""
|
||||
after_label: str
|
||||
after_label_en: str = ""
|
||||
|
||||
|
||||
class NavItemOut(ORMModel):
|
||||
id: int
|
||||
label: str
|
||||
label_en: str = ""
|
||||
href: str
|
||||
|
||||
|
||||
class HeroOut(ORMModel):
|
||||
id: int
|
||||
kicker: str
|
||||
kicker_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
title_highlight: str
|
||||
title_highlight_en: str = ""
|
||||
subtitle: str
|
||||
subtitle_en: str = ""
|
||||
badge_value: str
|
||||
badge_value_en: str = ""
|
||||
badge_label: str
|
||||
badge_label_en: str = ""
|
||||
cta_primary_label: str
|
||||
cta_primary_label_en: str = ""
|
||||
cta_primary_href: str
|
||||
cta_secondary_label: str
|
||||
cta_secondary_label_en: str = ""
|
||||
cta_secondary_href: str
|
||||
scroll_label: str
|
||||
scroll_label_en: str = ""
|
||||
|
||||
|
||||
class StoryOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
paragraphs: list[str]
|
||||
paragraphs_en: Optional[list[str]] = None
|
||||
quote: str
|
||||
quote_en: str = ""
|
||||
quote_author: str
|
||||
quote_author_en: str = ""
|
||||
stat1_value: str
|
||||
stat1_value_en: str = ""
|
||||
stat1_label: str
|
||||
stat1_label_en: str = ""
|
||||
stat2_value: str
|
||||
stat2_value_en: str = ""
|
||||
stat2_label: str
|
||||
stat2_label_en: str = ""
|
||||
link_label: str
|
||||
link_label_en: str = ""
|
||||
link_href: str
|
||||
bento: list[dict]
|
||||
bento_en: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SectionHeadingOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
label_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
image: str
|
||||
cta_label: str
|
||||
cta_label_en: str = ""
|
||||
cta_href: str
|
||||
|
||||
|
||||
@@ -165,91 +237,137 @@ class PageHeroOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
image: str
|
||||
|
||||
|
||||
class InvestmentModelOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
points: list[str]
|
||||
points_en: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ProcessStepOut(ORMModel):
|
||||
id: int
|
||||
step: str
|
||||
step_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class OfficeOut(ORMModel):
|
||||
id: int
|
||||
city: str
|
||||
city_en: str = ""
|
||||
address: str
|
||||
address_en: str = ""
|
||||
hours: str
|
||||
hours_en: str = ""
|
||||
|
||||
|
||||
class GalleryImageOut(ORMModel):
|
||||
id: int
|
||||
src: str
|
||||
alt: str
|
||||
alt_en: str = ""
|
||||
|
||||
|
||||
class StatusLabelOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class ContactSectionOut(ORMModel):
|
||||
id: int
|
||||
label: str
|
||||
label_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
address_label: str
|
||||
address_label_en: str = ""
|
||||
phone_label: str
|
||||
phone_label_en: str = ""
|
||||
email_label: str
|
||||
email_label_en: str = ""
|
||||
name_label: str
|
||||
name_label_en: str = ""
|
||||
name_placeholder: str
|
||||
name_placeholder_en: str = ""
|
||||
phone_field_label: str
|
||||
phone_field_label_en: str = ""
|
||||
phone_placeholder: str
|
||||
phone_placeholder_en: str = ""
|
||||
note_label: str
|
||||
note_label_en: str = ""
|
||||
note_optional_label: str
|
||||
note_optional_label_en: str = ""
|
||||
note_placeholder: str
|
||||
note_placeholder_en: str = ""
|
||||
submit_label: str
|
||||
submit_label_en: str = ""
|
||||
sending_label: str
|
||||
sending_label_en: str = ""
|
||||
success_title: str
|
||||
success_title_en: str = ""
|
||||
success_text: str
|
||||
success_text_en: str = ""
|
||||
success_button_label: str
|
||||
success_button_label_en: str = ""
|
||||
|
||||
|
||||
class NewsletterOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
placeholder: str
|
||||
placeholder_en: str = ""
|
||||
button_label: str
|
||||
button_label_en: str = ""
|
||||
sending_label: str
|
||||
sending_label_en: str = ""
|
||||
success_text: str
|
||||
success_text_en: str = ""
|
||||
|
||||
|
||||
class FooterOut(ORMModel):
|
||||
id: int
|
||||
about_text: str
|
||||
about_text_en: str = ""
|
||||
pages_heading: str
|
||||
pages_heading_en: str = ""
|
||||
services_heading: str
|
||||
services_heading_en: str = ""
|
||||
contact_heading: str
|
||||
contact_heading_en: str = ""
|
||||
copyright: str
|
||||
copyright_en: str = ""
|
||||
tagline: str
|
||||
tagline_en: str = ""
|
||||
socials: list[dict]
|
||||
socials_en: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SeoMetaOut(ORMModel):
|
||||
id: int
|
||||
page_key: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
description: str
|
||||
description_en: str = ""
|
||||
|
||||
|
||||
class ContactMessageOut(ORMModel):
|
||||
@@ -275,10 +393,14 @@ class ContactIn(BaseModel):
|
||||
name: str = Field(min_length=3, max_length=128)
|
||||
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
|
||||
note: str = Field(default="", max_length=2000)
|
||||
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
|
||||
website: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
class NewsletterIn(BaseModel):
|
||||
email: EmailStr
|
||||
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
|
||||
website: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
# ---------- ورودیهای پنل مدیریت ----------
|
||||
@@ -301,6 +423,7 @@ class ChangePasswordIn(BaseModel):
|
||||
|
||||
class BrandIn(BaseModel):
|
||||
name: str = ""
|
||||
name_en: str = ""
|
||||
logo: str = ""
|
||||
hero: str = ""
|
||||
contact_bg: str = ""
|
||||
@@ -308,228 +431,343 @@ class BrandIn(BaseModel):
|
||||
phone_href: str = ""
|
||||
email: str = ""
|
||||
address: str = ""
|
||||
address_en: str = ""
|
||||
|
||||
|
||||
class ServiceIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
icon: str = "draft"
|
||||
|
||||
|
||||
class ProjectIn(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
location: str = ""
|
||||
location_en: str = ""
|
||||
year: str = ""
|
||||
year_en: str = ""
|
||||
status: Literal["done", "progress"] = "done"
|
||||
image: str = ""
|
||||
gallery: list[str] = []
|
||||
area: str = ""
|
||||
area_en: str = ""
|
||||
floors: str = ""
|
||||
floors_en: str = ""
|
||||
units: str = ""
|
||||
units_en: str = ""
|
||||
client: str = ""
|
||||
client_en: str = ""
|
||||
summary: str = ""
|
||||
summary_en: str = ""
|
||||
body: list[str] = []
|
||||
body_en: list[str] = []
|
||||
|
||||
|
||||
EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
def validate_doc_body(value: object) -> dict:
|
||||
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
|
||||
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و
|
||||
آدرسهای href/src آن به اسکیمهای امن محدود شوند."""
|
||||
if not value:
|
||||
return dict(EMPTY_DOC)
|
||||
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
|
||||
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
|
||||
return value
|
||||
return sanitize_doc(value)
|
||||
|
||||
|
||||
class ArticleIn(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
date: str = ""
|
||||
date_en: str = ""
|
||||
category: str = ""
|
||||
category_en: str = ""
|
||||
image: str = ""
|
||||
image_alt: str = ""
|
||||
image_title: str = ""
|
||||
read: str = ""
|
||||
read_en: str = ""
|
||||
excerpt: str = ""
|
||||
excerpt_en: str = ""
|
||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
faqs: list[dict] = []
|
||||
faqs_en: list[dict] = []
|
||||
status: Literal["draft", "published"] = "draft"
|
||||
seo_keyphrase: str = Field(default="", max_length=128)
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
|
||||
|
||||
class TestimonialIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
name_en: str = ""
|
||||
role: str = ""
|
||||
role_en: str = ""
|
||||
quote: str = ""
|
||||
quote_en: str = ""
|
||||
|
||||
|
||||
class FaqIn(BaseModel):
|
||||
question: str = Field(min_length=1)
|
||||
question_en: str = ""
|
||||
answer: str = ""
|
||||
answer_en: str = ""
|
||||
|
||||
|
||||
class TeamMemberIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
name_en: str = ""
|
||||
role: str = ""
|
||||
role_en: str = ""
|
||||
bio: str = ""
|
||||
bio_en: str = ""
|
||||
|
||||
|
||||
class TimelineItemIn(BaseModel):
|
||||
year: str = Field(min_length=1, max_length=32)
|
||||
year_en: str = ""
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
class StatIn(BaseModel):
|
||||
value: int
|
||||
suffix: str = ""
|
||||
suffix_en: str = ""
|
||||
label: str = Field(min_length=1, max_length=128)
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class WhyUsItemIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class BeforeAfterIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
before: str = ""
|
||||
after: str = ""
|
||||
before_label: str = ""
|
||||
before_label_en: str = ""
|
||||
after_label: str = ""
|
||||
after_label_en: str = ""
|
||||
|
||||
|
||||
class NavItemIn(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=64)
|
||||
label_en: str = ""
|
||||
href: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class HeroIn(BaseModel):
|
||||
kicker: str = ""
|
||||
kicker_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
title_highlight: str = ""
|
||||
title_highlight_en: str = ""
|
||||
subtitle: str = ""
|
||||
subtitle_en: str = ""
|
||||
badge_value: str = ""
|
||||
badge_value_en: str = ""
|
||||
badge_label: str = ""
|
||||
badge_label_en: str = ""
|
||||
cta_primary_label: str = ""
|
||||
cta_primary_label_en: str = ""
|
||||
cta_primary_href: str = ""
|
||||
cta_secondary_label: str = ""
|
||||
cta_secondary_label_en: str = ""
|
||||
cta_secondary_href: str = ""
|
||||
scroll_label: str = ""
|
||||
scroll_label_en: str = ""
|
||||
|
||||
|
||||
class StoryIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
paragraphs: list[str] = []
|
||||
paragraphs_en: list[str] = []
|
||||
quote: str = ""
|
||||
quote_en: str = ""
|
||||
quote_author: str = ""
|
||||
quote_author_en: str = ""
|
||||
stat1_value: str = ""
|
||||
stat1_value_en: str = ""
|
||||
stat1_label: str = ""
|
||||
stat1_label_en: str = ""
|
||||
stat2_value: str = ""
|
||||
stat2_value_en: str = ""
|
||||
stat2_label: str = ""
|
||||
stat2_label_en: str = ""
|
||||
link_label: str = ""
|
||||
link_label_en: str = ""
|
||||
link_href: str = ""
|
||||
bento: list[dict] = []
|
||||
bento_en: list[dict] = []
|
||||
|
||||
|
||||
class SectionHeadingIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=64)
|
||||
label: str = ""
|
||||
label_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
image: str = ""
|
||||
cta_label: str = ""
|
||||
cta_label_en: str = ""
|
||||
cta_href: str = ""
|
||||
|
||||
|
||||
class PageHeroIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=32)
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
class InvestmentModelIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
points: list[str] = []
|
||||
points_en: list[str] = []
|
||||
|
||||
|
||||
class ProcessStepIn(BaseModel):
|
||||
step: str = ""
|
||||
step_en: str = ""
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class OfficeIn(BaseModel):
|
||||
city: str = Field(min_length=1, max_length=64)
|
||||
city_en: str = ""
|
||||
address: str = ""
|
||||
address_en: str = ""
|
||||
hours: str = ""
|
||||
hours_en: str = ""
|
||||
|
||||
|
||||
class GalleryImageIn(BaseModel):
|
||||
src: str = ""
|
||||
alt: str = ""
|
||||
alt_en: str = ""
|
||||
|
||||
|
||||
class StatusLabelIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=16)
|
||||
label: str = Field(min_length=1, max_length=64)
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class ContactSectionIn(BaseModel):
|
||||
label: str = ""
|
||||
label_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
address_label: str = ""
|
||||
address_label_en: str = ""
|
||||
phone_label: str = ""
|
||||
phone_label_en: str = ""
|
||||
email_label: str = ""
|
||||
email_label_en: str = ""
|
||||
name_label: str = ""
|
||||
name_label_en: str = ""
|
||||
name_placeholder: str = ""
|
||||
name_placeholder_en: str = ""
|
||||
phone_field_label: str = ""
|
||||
phone_field_label_en: str = ""
|
||||
phone_placeholder: str = ""
|
||||
phone_placeholder_en: str = ""
|
||||
note_label: str = ""
|
||||
note_label_en: str = ""
|
||||
note_optional_label: str = ""
|
||||
note_optional_label_en: str = ""
|
||||
note_placeholder: str = ""
|
||||
note_placeholder_en: str = ""
|
||||
submit_label: str = ""
|
||||
submit_label_en: str = ""
|
||||
sending_label: str = ""
|
||||
sending_label_en: str = ""
|
||||
success_title: str = ""
|
||||
success_title_en: str = ""
|
||||
success_text: str = ""
|
||||
success_text_en: str = ""
|
||||
success_button_label: str = ""
|
||||
success_button_label_en: str = ""
|
||||
|
||||
|
||||
class NewsletterSectionIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
placeholder: str = ""
|
||||
placeholder_en: str = ""
|
||||
button_label: str = ""
|
||||
button_label_en: str = ""
|
||||
sending_label: str = ""
|
||||
sending_label_en: str = ""
|
||||
success_text: str = ""
|
||||
success_text_en: str = ""
|
||||
|
||||
|
||||
class FooterIn(BaseModel):
|
||||
about_text: str = ""
|
||||
about_text_en: str = ""
|
||||
pages_heading: str = ""
|
||||
pages_heading_en: str = ""
|
||||
services_heading: str = ""
|
||||
services_heading_en: str = ""
|
||||
contact_heading: str = ""
|
||||
contact_heading_en: str = ""
|
||||
copyright: str = ""
|
||||
copyright_en: str = ""
|
||||
tagline: str = ""
|
||||
tagline_en: str = ""
|
||||
socials: list[dict] = []
|
||||
socials_en: list[dict] = []
|
||||
|
||||
|
||||
class SeoMetaIn(BaseModel):
|
||||
page_key: str = Field(min_length=1, max_length=32)
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
description: str = ""
|
||||
description_en: str = ""
|
||||
|
||||
|
||||
class SeoMetaPatch(BaseModel):
|
||||
page_key: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
|
||||
|
||||
class MessageReadIn(BaseModel):
|
||||
@@ -551,15 +789,26 @@ ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
|
||||
class ArticlePatch(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
date_en: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_en: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
image_alt: Optional[str] = None
|
||||
image_title: Optional[str] = None
|
||||
read: Optional[str] = None
|
||||
read_en: Optional[str] = None
|
||||
excerpt: Optional[str] = None
|
||||
excerpt_en: Optional[str] = None
|
||||
body: Optional[dict] = None
|
||||
body_en: Optional[dict] = None
|
||||
faqs: Optional[list[dict]] = None
|
||||
faqs_en: Optional[list[dict]] = None
|
||||
status: Optional[Literal["draft", "published"]] = None
|
||||
seo_keyphrase: Optional[str] = None
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
||||
FaqPatch = _make_patch("FaqPatch", FaqIn)
|
||||
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
|
||||
|
||||
+38
-6
@@ -11,7 +11,7 @@ seed میشوند و دادههای قبلی دستنخورده می
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
from . import models, seed_data
|
||||
from . import models, seed_data, seed_data_en
|
||||
from .auth import hash_password
|
||||
from .config import settings
|
||||
from .database import Base, SessionLocal, engine
|
||||
@@ -46,6 +46,35 @@ CONTENT: list[tuple[Any, list[dict], str]] = [
|
||||
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
|
||||
]
|
||||
|
||||
# ترجمه انگلیسی همان جدولها — همترتیب با CONTENT تا با zip ادغام شود
|
||||
EN_CONTENT: list[list[dict]] = [
|
||||
[seed_data_en.BRAND],
|
||||
[seed_data_en.HERO],
|
||||
[seed_data_en.STORY],
|
||||
seed_data_en.SECTION_HEADINGS,
|
||||
seed_data_en.PAGE_HEROES,
|
||||
seed_data_en.NAV_ITEMS,
|
||||
seed_data_en.INVESTMENT_MODELS,
|
||||
seed_data_en.PROCESS_STEPS,
|
||||
seed_data_en.OFFICES,
|
||||
seed_data_en.GALLERY_IMAGES,
|
||||
seed_data_en.STATUS_LABELS,
|
||||
[seed_data_en.CONTACT_SECTION],
|
||||
[seed_data_en.NEWSLETTER],
|
||||
[seed_data_en.FOOTER],
|
||||
seed_data_en.SERVICES,
|
||||
seed_data_en.STATS,
|
||||
seed_data_en.PROJECTS,
|
||||
seed_data_en.ARTICLES,
|
||||
seed_data_en.TESTIMONIALS,
|
||||
seed_data_en.FAQS,
|
||||
seed_data_en.TEAM,
|
||||
seed_data_en.TIMELINE,
|
||||
seed_data_en.WHY,
|
||||
[seed_data_en.BEFORE_AFTER],
|
||||
seed_data_en.SEO_META,
|
||||
]
|
||||
|
||||
|
||||
def seed(force: bool = False) -> None:
|
||||
db = SessionLocal()
|
||||
@@ -57,16 +86,19 @@ def seed(force: bool = False) -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
for model, rows, label in CONTENT:
|
||||
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT):
|
||||
if db.query(model).count() > 0:
|
||||
continue
|
||||
for idx, row in enumerate(rows):
|
||||
payload = dict(row)
|
||||
# فایل فارسی مرجع است؛ فقط فیلدهای *_en از فایل انگلیسی اضافه میشود
|
||||
payload = {**dict(en_rows[idx]), **dict(row)} if idx < len(en_rows) else dict(row)
|
||||
if hasattr(model, "position") and "position" not in payload:
|
||||
payload["position"] = idx
|
||||
# بدنه مقالات در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||
if model is models.Article and isinstance(payload.get("body"), list):
|
||||
payload["body"] = paragraphs_to_doc(payload["body"])
|
||||
# بدنه مقاله (فارسی و انگلیسی) در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||
if model is models.Article:
|
||||
for body_key in ("body", "body_en"):
|
||||
if isinstance(payload.get(body_key), list):
|
||||
payload[body_key] = paragraphs_to_doc(payload[body_key])
|
||||
db.add(model(**payload))
|
||||
|
||||
if db.query(models.AdminUser).count() == 0:
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
"""ترجمه انگلیسی دادههای اولیه سایت — ساختار یکبهیک با seed_data.py.
|
||||
|
||||
هر فهرست همطول و همترتیب با معادل فارسی خود است تا seed.py بتواند با zip
|
||||
ادغام کند و backfill مهاجرت بتواند سطرهای موجود را پر کند. فقط فیلدهای
|
||||
`*_en` ملاک ادغاماند؛ کلیدهای همنام با فارسی (مثل title یا body) صرفاً
|
||||
برای خواندن راحتتر مترجم آمدهاند و در ادغام نادیده گرفته میشوند.
|
||||
فیلدهای تطبیق (slug، key، page_key، src) عیناً مقدار فارسیاند.
|
||||
"""
|
||||
|
||||
from .seed_data import IMAGES
|
||||
|
||||
BRAND = {
|
||||
"name": "گروه ساختمانی آرامش",
|
||||
"name_en": "Aramesh Construction Group",
|
||||
"address": "تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم",
|
||||
"address_en": "Valiasr St., Aramesh Tower, 18th Floor, Tehran, Iran",
|
||||
}
|
||||
|
||||
SERVICES = [
|
||||
{"title": "طراحی معماری", "title_en": "Architectural Design", "desc": "خلق فرمهای معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشههای اجرایی و جزئیات نما.", "desc_en": "Contemporary forms with an Iranian eye — from the first concept to construction drawings and facade details.", "icon": "draft"},
|
||||
{"title": "ساخت برجهای مسکونی", "title_en": "Residential Towers", "desc": "اجرای برجهای بلندمرتبه با استانداردهای سازهای بینالمللی و کیفیت تحویل بینقص.", "desc_en": "High-rise execution built on international structural standards and a flawless handover quality.", "icon": "tower"},
|
||||
{"title": "مجتمعهای تجاری", "title_en": "Commercial Complexes", "desc": "طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.", "desc_en": "Retail destinations designed and built around precise visitor-flow studies and long-term returns.", "icon": "mall"},
|
||||
{"title": "ویلاسازی", "title_en": "Private Villas", "desc": "ویلاهای اختصاصی همنشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.", "desc_en": "Bespoke villas in harmony with nature — privacy, natural light and enduring materials.", "icon": "villa"},
|
||||
{"title": "بازسازی", "title_en": "Renovation", "desc": "بازآفرینی ساختمانهای موجود با ارتقای سازه، نما و تجربه زندگی در فضا.", "desc_en": "Reimagining existing buildings with upgraded structure, facades and living experience.", "icon": "renew"},
|
||||
{"title": "مدیریت پروژه", "title_en": "Project Management", "desc": "کنترل همزمان زمان، هزینه و کیفیت با گزارشدهی شفاف در تمام مراحل ساخت.", "desc_en": "Controlling time, cost and quality in parallel with transparent reporting at every stage.", "icon": "manage"},
|
||||
{"title": "سرمایهگذاری ساختمانی", "title_en": "Real Estate Investment", "desc": "ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.", "desc_en": "Transparent partnership structures, risk assessment and property portfolios tailored to your capital.", "icon": "invest"},
|
||||
{"title": "محوطهسازی و لنداسکیپ", "title_en": "Landscape Design", "desc": "طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.", "desc_en": "Courtyards, green roofs and pathways — architecture carried on into the open air.", "icon": "landscape"},
|
||||
]
|
||||
|
||||
STATS = [
|
||||
{"value": 21, "suffix": "+", "suffix_en": "+", "label": "سال تجربه ساخت", "label_en": "Years of building experience"},
|
||||
{"value": 47, "suffix": "", "suffix_en": "", "label": "پروژه تحویلشده", "label_en": "Projects delivered"},
|
||||
{"value": 860, "suffix": "هزار", "suffix_en": "K", "label": "مترمربع زیربنا", "label_en": "Square meters built"},
|
||||
{"value": 1200, "suffix": "+", "suffix_en": "+", "label": "خانواده ساکن", "label_en": "Resident families"},
|
||||
]
|
||||
|
||||
PROJECTS = [
|
||||
{
|
||||
"slug": "aramesh-elahieh",
|
||||
"title": "برج آرامش الهیه",
|
||||
"title_en": "Aramesh Elahieh Tower",
|
||||
"location": "تهران، الهیه",
|
||||
"location_en": "Elahieh, Tehran",
|
||||
"year": "۱۴۰۲",
|
||||
"year_en": "2023",
|
||||
"area": "۲۴٬۰۰۰ متر مربع",
|
||||
"area_en": "24,000 m²",
|
||||
"floors": "۱۸ طبقه",
|
||||
"floors_en": "18 floors",
|
||||
"units": "۵۶ واحد",
|
||||
"units_en": "56 units",
|
||||
"client": "سرمایهگذاری خصوصی",
|
||||
"client_en": "Private investor",
|
||||
"summary": "یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوستهای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.",
|
||||
"summary_en": "A luxury residential tower with travertine cladding and a double-skin facade that cuts energy use by up to 35%.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Aramesh Elahieh Tower was designed on a site with a natural northward slope — an opportunity that placed the lobby, pool and shared spaces on three different levels and gave every unit an unobstructed view of the city.",
|
||||
"The outer shell combines light travertine stone with floor-to-ceiling triple-glazed windows. Copper-toned vertical louvers break the direct summer sun while letting winter light pour in.",
|
||||
"Construction was completed in thirty-two months, and every unit was sold before handover.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "parseh-mall",
|
||||
"title": "مجتمع تجاری پارسه",
|
||||
"title_en": "Parseh Shopping Mall",
|
||||
"location": "اصفهان، مرکز شهر",
|
||||
"location_en": "City center, Isfahan",
|
||||
"year": "۱۴۰۱",
|
||||
"year_en": "2022",
|
||||
"area": "۳۸٬۵۰۰ متر مربع",
|
||||
"area_en": "38,500 m²",
|
||||
"floors": "۷ طبقه",
|
||||
"floors_en": "7 floors",
|
||||
"units": "۱۴۰ واحد تجاری",
|
||||
"units_en": "140 retail units",
|
||||
"client": "گروه سرمایهگذاری پارسه",
|
||||
"client_en": "Parseh Investment Group",
|
||||
"summary": "مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را بهصورت طبیعی در همه ترازها توزیع میکند.",
|
||||
"summary_en": "A retail center with a central atrium and roof light-wells that naturally distribute visitor flow across every level.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"A visitor-behavior study before design showed that the shortest vertical path was the key to the complex's commercial success — so the central atrium with diagonal escalators sits at the heart of the plan.",
|
||||
"Low-E glazing on the roof light-well carries daylight down to level B2 and measurably cuts artificial lighting consumption.",
|
||||
"The complex opened two months ahead of the contractual schedule.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "daryakenar-villas",
|
||||
"title": "ویلاهای دریاکنار",
|
||||
"title_en": "Daryakenar Villas",
|
||||
"location": "مازندران، نوشهر",
|
||||
"location_en": "Nowshahr, Mazandaran",
|
||||
"year": "۱۴۰۰",
|
||||
"year_en": "2021",
|
||||
"area": "۹٬۲۰۰ متر مربع",
|
||||
"area_en": "9,200 m²",
|
||||
"floors": "۲ طبقه",
|
||||
"floors_en": "2 floors",
|
||||
"units": "۱۲ ویلا",
|
||||
"units_en": "12 villas",
|
||||
"client": "مجموعه خصوصی دریاکنار",
|
||||
"client_en": "Daryakenar Private Estate",
|
||||
"summary": "دوازده ویلای اختصاصی با چیدمان شانهای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم میکند.",
|
||||
"summary_en": "Twelve bespoke villas in a comb layout that gives each unit a direct sea view and complete privacy.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The comb layout of the villas is the result of dozens of view-angle studies: no villa overlooks another, and all of them open onto the sea.",
|
||||
"The materials answer the humid northern climate: thermowood, exposed concrete and salt-resistant coatings on the facade metals.",
|
||||
"The landscape was designed with native plants to keep annual irrigation demand to a minimum.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "niavaran-twins",
|
||||
"title": "برج دوقلوی نیاوران",
|
||||
"title_en": "Niavaran Twin Towers",
|
||||
"location": "تهران، نیاوران",
|
||||
"location_en": "Niavaran, Tehran",
|
||||
"year": "۱۳۹۹",
|
||||
"year_en": "2020",
|
||||
"area": "۳۱٬۷۰۰ متر مربع",
|
||||
"area_en": "31,700 m²",
|
||||
"floors": "۲۰ طبقه",
|
||||
"floors_en": "20 floors",
|
||||
"units": "۸۰ واحد",
|
||||
"units_en": "80 units",
|
||||
"client": "مشارکت با مالکان زمین",
|
||||
"client_en": "Partnership with landowners",
|
||||
"summary": "دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگها را در یک تراز واحد ادغام میکند.",
|
||||
"summary_en": "Two identical towers on a shared podium that merges amenities and parking into a single level.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The two towers rest on a shared podium — a decision that let the amenities, event hall and parking be shared between the buildings and reduced the footprint on the garden.",
|
||||
"The structure uses shear walls with post-tensioned slabs so the interior spans stay free and the floor plans remain customizable.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "kish-garden-tower",
|
||||
"title": "باغبرج کیش",
|
||||
"title_en": "Kish Garden Tower",
|
||||
"location": "کیش، مروارید",
|
||||
"location_en": "Marvàrid, Kish Island",
|
||||
"year": "۱۴۰۵ (پیشبینی)",
|
||||
"year_en": "2026 (expected)",
|
||||
"area": "۱۸٬۴۰۰ متر مربع",
|
||||
"area_en": "18,400 m²",
|
||||
"floors": "۱۴ طبقه",
|
||||
"floors_en": "14 floors",
|
||||
"units": "۶۲ واحد اقامتی",
|
||||
"units_en": "62 hospitality units",
|
||||
"client": "صندوق پروژهمحور آرامش",
|
||||
"client_en": "Aramesh Project Fund",
|
||||
"summary": "باغبرجی اقامتی با تراسهای سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.",
|
||||
"summary_en": "A hospitality garden tower with continuous green terraces, currently under construction — 48% complete.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The continuous green terraces form a living skin that measurably lowers the facade surface temperature in the island's hot climate.",
|
||||
"The structure has reached the ninth floor, and facade installation has begun on the southern elevation.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "aramesh-business-center",
|
||||
"title": "مرکز تجاری آرامش تهران",
|
||||
"title_en": "Aramesh Business Center Tehran",
|
||||
"location": "تهران، سعادتآباد",
|
||||
"location_en": "Saadatabad, Tehran",
|
||||
"year": "۱۴۰۶ (پیشبینی)",
|
||||
"year_en": "2027 (expected)",
|
||||
"area": "۲۷٬۳۰۰ متر مربع",
|
||||
"area_en": "27,300 m²",
|
||||
"floors": "۱۲ طبقه",
|
||||
"floors_en": "12 floors",
|
||||
"units": "۹۰ دفتر اداری",
|
||||
"units_en": "90 offices",
|
||||
"client": "مشارکت با هلدینگ آرامش",
|
||||
"client_en": "Partnership with Aramesh Holding",
|
||||
"summary": "مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفتکاری.",
|
||||
"summary_en": "An office-retail complex with a transparent central core and double-height lobby; currently in structural works.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"The space planning was built around flexible offices: every level can be divided into two, three or six independent units.",
|
||||
"Mechanical installations are progressing in parallel with the structural works to shorten the overall timeline.",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
BEFORE_AFTER = {
|
||||
"title": "از کانسپت تا خلق واقعیت",
|
||||
"title_en": "From Concept to Reality",
|
||||
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل میشود.",
|
||||
"lead_en": "Drag the handle to see how a building facade turns from a raw concept into its modern form.",
|
||||
"before_label": "کانسپت اولیه",
|
||||
"before_label_en": "Initial concept",
|
||||
"after_label": "پروژه تکمیلشده",
|
||||
"after_label_en": "Completed project",
|
||||
}
|
||||
|
||||
TIMELINE = [
|
||||
{"year": "۱۳۸۳", "year_en": "2004", "title": "آغاز مسیر", "title_en": "The Journey Begins", "desc": "تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.", "desc_en": "Aramesh Architecture Office is founded by a four-person team, taking on its first renovation project in Tehran.", "image": IMAGES[2]},
|
||||
{"year": "۱۳۸۵", "year_en": "2006", "title": "نخستین ساخت مستقل", "title_en": "First Independent Build", "desc": "تحویل اولین ساختمان مسکونی هفتطبقه؛ آغاز همکاری با پیمانکاران سازهای بزرگ.", "desc_en": "Delivery of the first seven-story residential building and the start of partnerships with major structural contractors.", "image": IMAGES[0]},
|
||||
{"year": "۱۳۸۷", "year_en": "2008", "title": "ورود به بازار تجاری", "title_en": "Entering Commercial Market", "desc": "طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.", "desc_en": "Design and execution of the first commercial complex, driven by capital returns and visitor flow.", "image": IMAGES[1]},
|
||||
{"year": "۱۳۸۹", "year_en": "2010", "title": "استاندارد بینالمللی", "title_en": "International Standards", "desc": "استقرار نظام کنترل کیفیت و دریافت گواهینامههای مدیریت پروژه.", "desc_en": "A quality-control system is established and project-management certifications are earned.", "image": IMAGES[4]},
|
||||
{"year": "۱۳۹۵", "year_en": "2016", "title": "بلندمرتبهسازی", "title_en": "Going High-Rise", "desc": "آغاز ساخت نخستین برج بیستطبقه شرکت در شمال تهران.", "desc_en": "Construction begins on the company's first twenty-story tower in northern Tehran.", "image": IMAGES[3]},
|
||||
{"year": "۱۴۰۳", "year_en": "Today", "title": "گروه ساختمانی آرامش امروز", "title_en": "Aramesh Construction Group Today", "desc": "پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایهگذاری ساختمانی اختصاصی.", "desc_en": "A portfolio of 47 projects, offices in three cities and a dedicated real estate investment fund.", "image": IMAGES[0]},
|
||||
]
|
||||
|
||||
WHY = [
|
||||
{"title": "کیفیت بدون مصالحه", "title_en": "Quality Without Compromise", "desc": "انتخاب مصالح و مجریان بر اساس ماندگاری چنددههای، نه سریعترین تحویل.", "desc_en": "Materials and subcontractors are chosen for decades of durability, not the fastest delivery."},
|
||||
{"title": "شفافیت مالی کامل", "title_en": "Full Financial Transparency", "desc": "گزارش ماهانه هزینهها و پیشرفت فیزیکی برای همه شرکای پروژه.", "desc_en": "Monthly reporting of costs and physical progress for every project partner."},
|
||||
{"title": "تحویل بهموقع", "title_en": "On-Time Delivery", "desc": "۹۴٪ پروژههای ما در بازه زمانی قراردادی یا زودتر تحویل داده شدهاند.", "desc_en": "94% of our projects have been delivered on or ahead of the contractual schedule."},
|
||||
{"title": "طراحی اختصاصی", "title_en": "Bespoke Design", "desc": "هیچ نقشهای دو بار اجرا نمیشود؛ هر پروژه هویت معماری خودش را دارد.", "desc_en": "No drawing is ever executed twice — every project has its own architectural identity."},
|
||||
{"title": "خدمات پس از تحویل", "title_en": "After-Sales Service", "desc": "پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.", "desc_en": "Five years of technical support and building-systems maintenance after handover."},
|
||||
{"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."},
|
||||
]
|
||||
|
||||
TESTIMONIALS = [
|
||||
{"name": "مهندس سارا کیانی", "name_en": "Sara Kiani", "role": "سرمایهگذار پروژه پارسه", "role_en": "Investor, Parseh Mall project", "quote": "گزارشهای ماهانه گروه ساختمانی آرامش آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.", "quote_en": "Aramesh's monthly reports were so precise that I never had to worry about my investment. The project opened two months ahead of schedule."},
|
||||
{"name": "دکتر امیرحسین راد", "name_en": "Dr. Amirhossein Rad", "role": "ساکن برج الهیه", "role_en": "Resident, Elahieh Tower", "quote": "کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها.", "quote_en": "The build quality shows in details nobody usually thinks about — from the acoustic insulation of the windows to the indirect lighting in the corridors."},
|
||||
{"name": "مریم دلاور", "name_en": "Maryam Delavar", "role": "مدیر مجموعه ویلاهای دریاکنار", "role_en": "Manager, Daryakenar Villas", "quote": "همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند.", "quote_en": "Working with the design team was a truly professional experience. They listened to our ideas and translated them into architecture."},
|
||||
]
|
||||
|
||||
TEAM = [
|
||||
{"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیانگذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."},
|
||||
{"name": "نگار شریفی", "name_en": "Negar Sharifi", "role": "مدیر ارشد طراحی", "role_en": "Chief Design Officer", "bio": "معمار ارشد، فارغالتحصیل دانشگاه تهران و میلان.", "bio_en": "Senior architect, graduate of the University of Tehran and Milan."},
|
||||
{"name": "بهزاد مرادی", "name_en": "Behzad Moradi", "role": "مدیر اجرایی پروژهها", "role_en": "Director of Project Execution", "bio": "مسئول کنترل کیفیت و زمانبندی همه کارگاهها.", "bio_en": "Responsible for quality control and scheduling across all sites."},
|
||||
{"name": "الهام نیکآیین", "name_en": "Elham Nikaein", "role": "مدیر سرمایهگذاری", "role_en": "Investment Director", "bio": "طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.", "bio_en": "Designs partnership structures and analyzes property returns."},
|
||||
]
|
||||
|
||||
ARTICLES = [
|
||||
{
|
||||
"slug": "natural-light",
|
||||
"title": "معماری آرام: چرا نور طبیعی گرانترین مصالح ساختمان است؟",
|
||||
"title_en": "Quiet Architecture: Why Is Natural Light the Most Expensive Building Material?",
|
||||
"date": "۲۴ مرداد ۱۴۰۳",
|
||||
"date_en": "August 14, 2024",
|
||||
"category": "معماری",
|
||||
"category_en": "Architecture",
|
||||
"read": "۶ دقیقه",
|
||||
"read_en": "6 min read",
|
||||
"excerpt": "نور طبیعی نهتنها کیفیت زندگی در فضا را تعیین میکند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر میگذارد.",
|
||||
"excerpt_en": "Natural light not only defines the quality of life in a space — it directly affects energy consumption and the long-term value of the property.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"In the construction market, a unit's value is usually measured in square meters. But our experience over the past twenty years shows that the quality of light is more decisive than a few extra meters.",
|
||||
"Building orientation, bay depth and floor-to-ceiling height are the three main variables of natural light. The decision on all three is made before the first plan is drawn — and is hardly reversible afterwards.",
|
||||
"In Aramesh Elahieh Tower, rotating the building mass 12 degrees off north added two hours of useful winter light to the units — a cost-free decision with a decades-long effect.",
|
||||
"Our advice for choosing a unit is simple: visit at mid-morning and late afternoon, and judge the quality of light at exactly the hours you are actually home.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "real-estate-portfolio",
|
||||
"title": "راهنمای انتخاب سبد سرمایهگذاری ملکی در بازار امروز",
|
||||
"title_en": "A Guide to Building a Real Estate Portfolio in Today's Market",
|
||||
"date": "۱۰ مرداد ۱۴۰۳",
|
||||
"date_en": "July 31, 2024",
|
||||
"category": "سرمایهگذاری",
|
||||
"category_en": "Investment",
|
||||
"read": "۹ دقیقه",
|
||||
"read_en": "9 min read",
|
||||
"excerpt": "سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.",
|
||||
"excerpt_en": "Three key criteria for building a balanced property portfolio, based on your time horizon and risk appetite.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"A balanced property portfolio mixes assets with different liquidity — not all capital in one long-term project, not all in ready units.",
|
||||
"Time horizon is the first criterion. If your plan is shorter than two years, pre-purchasing in a project that has just started is a risky choice.",
|
||||
"The second criterion is reporting transparency. Without monthly reports of physical and financial progress, assessing the state of your capital is practically impossible.",
|
||||
"The third criterion is diversity of use. A residential–commercial mix measurably reduces the volatility of portfolio returns in downturns.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "facade-quality",
|
||||
"title": "پنج نکته فنی که کیفیت نما را پس از ده سال مشخص میکند",
|
||||
"title_en": "Five Technical Details That Reveal Facade Quality After Ten Years",
|
||||
"date": "۲۸ تیر ۱۴۰۳",
|
||||
"date_en": "July 18, 2024",
|
||||
"category": "اجرا",
|
||||
"category_en": "Construction",
|
||||
"read": "۵ دقیقه",
|
||||
"read_en": "5 min read",
|
||||
"excerpt": "نمای ساختمان در سال اول زیبا به نظر میرسد؛ تفاوت کیفیت اجرا در سال دهم آشکار میشود.",
|
||||
"excerpt_en": "A facade looks beautiful in its first year; the difference in workmanship shows in its tenth.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Mechanical fixings of facade stone matter more than the stone itself. Adhesive fixing has a short life in Iran's hot-and-cold climate.",
|
||||
"Contraction joints must be designed on the drawing, not decided on site — their absence is the first cause of horizontal facade cracks.",
|
||||
"Window perimeter waterproofing is the most frequent point of failure. Installing waterproofing tape before the sill goes in removes that risk.",
|
||||
"Hot-dip galvanized sub-structures cost marginally more than painted ones — and last many times longer.",
|
||||
"Finally, take the annual facade inspection seriously: repairing one joint costs next to nothing compared with redoing the whole facade.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "commercial-yield",
|
||||
"title": "چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟",
|
||||
"title_en": "How to Estimate a Commercial Project's Yield Before Building It",
|
||||
"date": "۱۵ تیر ۱۴۰۳",
|
||||
"date_en": "July 5, 2024",
|
||||
"category": "سرمایهگذاری",
|
||||
"category_en": "Investment",
|
||||
"read": "۷ دقیقه",
|
||||
"read_en": "7 min read",
|
||||
"excerpt": "مدل سادهای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.",
|
||||
"excerpt_en": "A simple yield-estimation model based on visitor flow, unit mix and local rental rates.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"Estimating commercial yield starts with three numbers: daily visitor flow, the rental rate per square meter in the area, and the net-to-gross floor ratio.",
|
||||
"A common mistake is ignoring the upper levels. Without a well-designed vertical path, levels three and above usually fail to reach even half of the ground-floor rate.",
|
||||
"Finally, write the conservative scenario too — if the project still makes sense in the pessimistic case, the investment is a rational one.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "facade-materials",
|
||||
"title": "مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران",
|
||||
"title_en": "Facade Materials: Stone, Composite and Thermowood in Iran's Climate",
|
||||
"date": "۲ تیر ۱۴۰۳",
|
||||
"date_en": "June 22, 2024",
|
||||
"category": "اجرا",
|
||||
"category_en": "Construction",
|
||||
"read": "۸ دقیقه",
|
||||
"read_en": "8 min read",
|
||||
"excerpt": "هر مصالح نما در اقلیم متفاوتی درست کار میکند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر میکند.",
|
||||
"excerpt_en": "Every facade material works in the right climate; the wrong choice multiplies maintenance costs.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"In a dry climate, stone has the best durability — but its weight demands a precise sub-structure and structural calculations.",
|
||||
"Composite is the fast, light choice, provided you pick a fire-rated grade and account for thermal expansion along the sheet.",
|
||||
"In the humid northern climate, thermowood is the best aesthetic answer — with a twice-yearly maintenance schedule.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "renovate-or-rebuild",
|
||||
"title": "بازسازی یا نوسازی؟ معیارهای تصمیمگیری برای مالکان",
|
||||
"title_en": "Renovate or Rebuild? Decision Criteria for Property Owners",
|
||||
"date": "۲۰ خرداد ۱۴۰۳",
|
||||
"date_en": "June 9, 2024",
|
||||
"category": "معماری",
|
||||
"category_en": "Architecture",
|
||||
"read": "۶ دقیقه",
|
||||
"read_en": "6 min read",
|
||||
"excerpt": "اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی میتواند بازدهی بالاتری از تخریب کامل داشته باشد.",
|
||||
"excerpt_en": "If the structure's age and city regulations allow, renovation can outperform full demolition and rebuild.",
|
||||
"body": [],
|
||||
"body_en": [
|
||||
"First the structure is assessed: if seismic retrofitting costs more than 40% of a new build, rebuilding is the more rational option.",
|
||||
"The second criterion is today's urban regulations. Sometimes demolition grants you less permissible floor area — which changes the decision entirely.",
|
||||
"In renovation, the greatest added value comes from three places: the facade, the entrance and the re-planning of the interior.",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
FAQS = [
|
||||
{"question": "حداقل سرمایه برای مشارکت در پروژههای گروه ساختمانی آرامش چقدر است؟", "question_en": "What is the minimum capital to join Aramesh Construction Group projects?", "answer": "ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایهگذاری از سطح مشخصی آغاز میشوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه میشود.", "answer_en": "Each project's partnership structure is different, but investment units usually start from a defined level, and the exact details are presented in the initial assessment meeting."},
|
||||
{"question": "مدت زمان معمول ساخت یک برج مسکونی چقدر است؟", "question_en": "How long does it take to build a residential tower?", "answer": "بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.", "answer_en": "Depending on the number of floors and site conditions, a residential tower takes on average 28 to 42 months from permit issuance to key handover."},
|
||||
{"question": "آیا امکان تغییر نقشه داخلی واحد وجود دارد؟", "question_en": "Can the unit's interior layout be customized?", "answer": "بله. تا پیش از پایان مرحله سفتکاری، امکان شخصیسازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.", "answer_en": "Yes. Until the structural phase is complete, the interior plan can be customized in coordination with the design team and structural approval."},
|
||||
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "question_en": "What does the after-sales service include?", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری.", "answer_en": "Five years of technical support for mechanical and electrical systems, an annual inspection, and warranty on facade work and insulation."},
|
||||
{"question": "پروژهها در چه شهرهایی اجرا میشوند؟", "question_en": "In which cities do you build?", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است.", "answer_en": "Tehran, Isfahan and the northern coasts are currently our main regions, and a project is also under construction on Kish Island."},
|
||||
]
|
||||
|
||||
NAV_ITEMS = [
|
||||
{"label": "صفحه اصلی", "label_en": "Home", "href": "/"},
|
||||
{"label": "درباره ما", "label_en": "About", "href": "/about"},
|
||||
{"label": "خدمات", "label_en": "Services", "href": "/services"},
|
||||
{"label": "پروژهها", "label_en": "Projects", "href": "/projects"},
|
||||
{"label": "سرمایهگذاری", "label_en": "Investment", "href": "/investment"},
|
||||
{"label": "وبلاگ", "label_en": "Blog", "href": "/blog"},
|
||||
{"label": "سوالات متداول", "label_en": "FAQ", "href": "/faq"},
|
||||
{"label": "تماس با ما", "label_en": "Contact", "href": "/contact"},
|
||||
]
|
||||
|
||||
HERO = {
|
||||
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
|
||||
"kicker_en": "Since 2004 — Tehran, Isfahan, Northern Coasts",
|
||||
"title": "ساختمانی که آرامش را معماری میکند",
|
||||
"title_en": "A building that architects tranquility",
|
||||
"title_highlight": "معماری",
|
||||
"title_highlight_en": "architects",
|
||||
"subtitle": "طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در سالها دوام میآورد.",
|
||||
"subtitle_en": "Design, construction and investment in luxury residential and commercial projects — to a standard you can see in the details and trust for years.",
|
||||
"badge_value": "۴۷",
|
||||
"badge_value_en": "47",
|
||||
"badge_label": "پروژه تحویلشده در بیستویک سال فعالیت حرفهای",
|
||||
"badge_label_en": "Projects delivered in twenty-one years of professional work",
|
||||
"cta_primary_label": "مشاهده پروژهها",
|
||||
"cta_primary_label_en": "View Projects",
|
||||
"cta_secondary_label": "مشاوره سرمایهگذاری",
|
||||
"cta_secondary_label_en": "Investment Advisory",
|
||||
"scroll_label": "اسکرول کنید",
|
||||
"scroll_label_en": "Scroll",
|
||||
}
|
||||
|
||||
STORY = {
|
||||
"title": "درباره ی ما",
|
||||
"title_en": "About Us",
|
||||
"paragraphs": [],
|
||||
"paragraphs_en": [
|
||||
"Aramesh Construction Group began as a small architecture office and is today one of Iran's recognized names in luxury construction. Through all these years one principle has not changed: a building must be made for living, not for a quick sale.",
|
||||
"Every project starts with a study of the land, light, climate and the residents' way of life; then the design, structural and execution teams carry it through a single process to key handover.",
|
||||
],
|
||||
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
|
||||
"quote_en": "\u201cA building, for us, is not just brick and concrete; it is a commitment to the quality of life of its residents. We build spaces that keep their value and identity as time passes.\u201d",
|
||||
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
|
||||
"quote_author_en": "Kamran Aramesh — CEO, Aramesh Construction Group",
|
||||
"stat1_value": "۹۴٪",
|
||||
"stat1_value_en": "94%",
|
||||
"stat1_label": "تحویل بهموقع پروژهها",
|
||||
"stat1_label_en": "On-time project delivery",
|
||||
"stat2_value": "۵ سال",
|
||||
"stat2_value_en": "5 years",
|
||||
"stat2_label": "پشتیبانی پس از تحویل",
|
||||
"stat2_label_en": "Post-handover support",
|
||||
"link_label": "داستان کامل ما",
|
||||
"link_label_en": "Our full story",
|
||||
"bento": [],
|
||||
"bento_en": [
|
||||
{"src": IMAGES[0], "alt": "Facade of an Aramesh residential tower in Tehran"},
|
||||
{"src": IMAGES[1], "alt": "Central atrium of Parseh Shopping Mall"},
|
||||
{"src": IMAGES[2], "alt": "Villas built on the northern coasts"},
|
||||
{"src": IMAGES[3], "alt": "Night view of the Niavaran Twin Towers"},
|
||||
],
|
||||
}
|
||||
|
||||
SECTION_HEADINGS = [
|
||||
{"key": "services", "title_en": "Services", "lead_en": "From the first line on paper to key handover, capital management and landscape design — every stage under one roof and one quality-control system."},
|
||||
{"key": "featured_projects", "title_en": "Featured Projects", "lead_en": "The work that says the most about our standard — both the delivered projects and the ones under construction today.", "cta_label_en": "All Projects"},
|
||||
{"key": "gallery", "title_en": "Gallery", "lead_en": "Frames from the sites and delivered projects of Aramesh Construction Group."},
|
||||
{"key": "timeline", "title_en": "Our Path", "lead_en": "From a four-person office to 47 projects. Walk through the timeline of our work."},
|
||||
{"key": "why_us", "title_en": "Why Aramesh Construction Group", "lead_en": "Six reasons clients and investors come back to us a second time."},
|
||||
{"key": "testimonials", "title_en": "Client Testimonials"},
|
||||
{"key": "team", "title_en": "Our Team", "lead_en": "The people who make each project's key decisions — and answer for them."},
|
||||
{"key": "articles", "label_en": "Blog", "title_en": "Latest Articles", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market.", "cta_label_en": "All Articles"},
|
||||
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Short answers to the questions investors and buyers ask most."},
|
||||
{"key": "investment_models", "title_en": "Partnership Models", "lead_en": "Depending on your capital, time horizon and risk appetite, one of these three paths is right for you."},
|
||||
{"key": "process", "title_en": "How We Work", "lead_en": "Four transparent steps, from the first meeting to key handover."},
|
||||
]
|
||||
|
||||
PAGE_HEROES = [
|
||||
{"key": "about", "title_en": "About Us", "lead_en": "Aramesh Construction Group is a team of architects, structural engineers and project managers working in luxury construction since 2004."},
|
||||
{"key": "services", "title_en": "Services", "lead_en": "Eight specialized fields covering a building project's full cycle, from idea to operation."},
|
||||
{"key": "projects", "title_en": "Projects", "lead_en": "A collection of residential towers, commercial centers and private villas — from delivered projects to today's active sites."},
|
||||
{"key": "investment", "title_en": "Investment", "lead_en": "Three clear paths into the luxury construction market, with transparent financial structures and regular reporting."},
|
||||
{"key": "blog", "title_en": "Blog", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market."},
|
||||
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Before you call, the answer to your question might be right here. If not, we would be glad to talk directly."},
|
||||
{"key": "contact", "title_en": "Contact Us", "lead_en": "For construction consultancy, investment or a project visit, our teams in three cities are ready to respond."},
|
||||
]
|
||||
|
||||
INVESTMENT_MODELS = [
|
||||
{
|
||||
"title": "مشارکت در ساخت",
|
||||
"title_en": "Joint Construction Partnership",
|
||||
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
|
||||
"desc_en": "The landowner and Aramesh share the project's profit; execution, permits and sales are on us.",
|
||||
"points": [],
|
||||
"points_en": ["Free land assessment", "Transparent contract with performance guarantee", "Monthly progress reporting"],
|
||||
},
|
||||
{
|
||||
"title": "صندوق پروژهمحور",
|
||||
"title_en": "Project-Based Fund",
|
||||
"desc": "سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.",
|
||||
"desc_en": "Investment in a specific project with purchasable units and a calculated yield at the end of the term.",
|
||||
"points": [],
|
||||
"points_en": ["Minimum 24-month term", "Quarterly valuation reports", "Investment units are transferable"],
|
||||
},
|
||||
{
|
||||
"title": "پیشخرید واحد",
|
||||
"title_en": "Pre-Purchase",
|
||||
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.",
|
||||
"desc_en": "Buying a residential or commercial unit in the early stages of construction, at a pre-completion price with staged payments.",
|
||||
"points": [],
|
||||
"points_en": ["Payments staged to match progress", "Interior layout customization", "Delivery-time guarantee"],
|
||||
},
|
||||
]
|
||||
|
||||
PROCESS_STEPS = [
|
||||
{"step": "۰۱", "step_en": "01", "title": "مطالعه و امکانسنجی", "title_en": "Study & Feasibility", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.", "desc_en": "Site review, urban regulations and an economic yield analysis of the project."},
|
||||
{"step": "۰۲", "step_en": "02", "title": "طراحی و فاز اجرایی", "title_en": "Design & Construction Drawings", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی.", "desc_en": "Architectural concept, structural and MEP design, and construction documentation."},
|
||||
{"step": "۰۳", "step_en": "03", "title": "اجرا و کنترل کیفیت", "title_en": "Execution & Quality Control", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.", "desc_en": "Construction with continuous supervision, monthly progress reports and cost control."},
|
||||
{"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."},
|
||||
]
|
||||
|
||||
OFFICES = [
|
||||
{"city": "تهران", "city_en": "Tehran", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "address_en": "Valiasr St., Aramesh Tower, 18th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸", "hours_en": "Saturday–Wednesday, 9 AM–6 PM"},
|
||||
{"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "address_en": "Chaharbagh Bala St., Parseh Complex, 6th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"},
|
||||
{"city": "نوشهر", "city_en": "Nowshahr", "address": "بلوار دریا، دفتر پروژه دریاکنار", "address_en": "Darya Blvd., Daryakenar Project Office", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶", "hours_en": "Saturday–Thursday, 10 AM–4 PM"},
|
||||
]
|
||||
|
||||
GALLERY_IMAGES = [
|
||||
{"src": IMAGES[0], "alt_en": "Aramesh Construction Group project, No. 1"},
|
||||
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 2"},
|
||||
{"src": IMAGES[2], "alt_en": "Aramesh Construction Group project, No. 3"},
|
||||
{"src": IMAGES[3], "alt_en": "Aramesh Construction Group project, No. 4"},
|
||||
{"src": IMAGES[4], "alt_en": "Aramesh Construction Group project, No. 5"},
|
||||
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 6"},
|
||||
]
|
||||
|
||||
STATUS_LABELS = [
|
||||
{"key": "done", "label_en": "Completed"},
|
||||
{"key": "progress", "label_en": "In Progress"},
|
||||
]
|
||||
|
||||
CONTACT_SECTION = {
|
||||
"label": "تماس با ما",
|
||||
"label_en": "Contact Us",
|
||||
"title": "پروژهتان را با ما در میان بگذارید",
|
||||
"title_en": "Tell Us About Your Project",
|
||||
"lead": "شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.",
|
||||
"lead_en": "Leave your number — our expert will call you within one business day for a free consultation session.",
|
||||
"address_label": "دفتر مرکزی",
|
||||
"address_label_en": "Head Office",
|
||||
"phone_label": "تلفن",
|
||||
"phone_label_en": "Phone",
|
||||
"email_label": "ایمیل",
|
||||
"email_label_en": "Email",
|
||||
"name_label": "نام",
|
||||
"name_label_en": "Name",
|
||||
"name_placeholder": "نام و نام خانوادگی",
|
||||
"name_placeholder_en": "Full name",
|
||||
"phone_field_label": "شماره تلفن",
|
||||
"phone_field_label_en": "Phone number",
|
||||
"note_label": "توضیحات",
|
||||
"note_label_en": "Details",
|
||||
"note_optional_label": "(اختیاری)",
|
||||
"note_optional_label_en": "(optional)",
|
||||
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
|
||||
"note_placeholder_en": "Tell us about your project or request…",
|
||||
"submit_label": "ارسال درخواست مشاوره",
|
||||
"submit_label_en": "Request a Consultation",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"sending_label_en": "Sending…",
|
||||
"success_title": "درخواست شما ثبت شد",
|
||||
"success_title_en": "Your Request Was Received",
|
||||
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.",
|
||||
"success_text_en": "Thank you for your trust. Our experts will contact you shortly.",
|
||||
"success_button_label": "ثبت درخواست جدید",
|
||||
"success_button_label_en": "Submit Another Request",
|
||||
}
|
||||
|
||||
NEWSLETTER = {
|
||||
"title": "خبرنامه گروه ساختمانی آرامش",
|
||||
"title_en": "Aramesh Construction Group Newsletter",
|
||||
"lead": "هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.",
|
||||
"lead_en": "One email a month: new projects, investment opportunities and notes from the architecture team.",
|
||||
"placeholder": "نشانی ایمیل شما",
|
||||
"placeholder_en": "Your email address",
|
||||
"button_label": "عضویت",
|
||||
"button_label_en": "Subscribe",
|
||||
"sending_label": "در حال ارسال…",
|
||||
"sending_label_en": "Sending…",
|
||||
"success_text": "عضویت شما ثبت شد.",
|
||||
"success_text_en": "Your subscription has been registered.",
|
||||
}
|
||||
|
||||
FOOTER = {
|
||||
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایهگذاری شفاف.",
|
||||
"about_text_en": "Since 2004, Aramesh Construction Group has been building for generations to come: calm architecture, precise execution and transparent investment.",
|
||||
"pages_heading": "صفحات",
|
||||
"pages_heading_en": "Pages",
|
||||
"services_heading": "خدمات",
|
||||
"services_heading_en": "Services",
|
||||
"contact_heading": "ارتباط با ما",
|
||||
"contact_heading_en": "Contact",
|
||||
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
|
||||
"copyright_en": "© 2025 Aramesh Construction Group — All rights reserved.",
|
||||
"tagline": "طراحی و ساخت با دقتِ معماری",
|
||||
"tagline_en": "Designed and built with architectural precision",
|
||||
"socials_en": [
|
||||
{"label": "Instagram", "href": "#", "icon": "instagram"},
|
||||
{"label": "LinkedIn", "href": "#", "icon": "linkedin"},
|
||||
{"label": "Telegram", "href": "#", "icon": "telegram"},
|
||||
],
|
||||
}
|
||||
|
||||
SEO_META = [
|
||||
{"page_key": "home", "title_en": "Aramesh Construction Group | Luxury Residential & Commercial Buildings", "description_en": "Design, construction and investment in luxury residential and commercial projects in Iran — 21 years of experience, 47 delivered projects."},
|
||||
{"page_key": "about", "title_en": "About Us | Aramesh Construction Group", "description_en": "Architects, structural engineers and project managers in luxury construction since 2004."},
|
||||
{"page_key": "services", "title_en": "Services | Aramesh Construction Group", "description_en": "Architectural design, high-rise construction, commercial complexes, villas, renovation and project management."},
|
||||
{"page_key": "projects", "title_en": "Projects | Aramesh Construction Group", "description_en": "Residential towers, commercial centers and private villas — delivered projects and active sites."},
|
||||
{"page_key": "investment", "title_en": "Investment | Aramesh Construction Group", "description_en": "Three transparent paths into the luxury construction market: joint construction, project-based funds and pre-purchase."},
|
||||
{"page_key": "blog", "title_en": "Blog | Aramesh Construction Group", "description_en": "Articles on architecture, construction and real estate investment from the Aramesh team."},
|
||||
{"page_key": "faq", "title_en": "FAQ | Aramesh Construction Group", "description_en": "Short answers to the most frequent questions from investors and buyers."},
|
||||
{"page_key": "contact", "title_en": "Contact Us | Aramesh Construction Group", "description_en": "Get in touch for construction consultancy, investment or a project visit — offices in Tehran, Isfahan and Nowshahr."},
|
||||
]
|
||||
+269
-29
@@ -2,8 +2,11 @@
|
||||
|
||||
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
||||
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
||||
title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
||||
|
||||
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو میشود؛
|
||||
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی میشوند.
|
||||
"""
|
||||
|
||||
import html
|
||||
@@ -22,6 +25,10 @@ from .database import get_db
|
||||
router = APIRouter()
|
||||
|
||||
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
|
||||
# صفحاتی که متای آنها از seo_meta خوانده میشود — کلید همان page_key است
|
||||
META_KEYS = ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
|
||||
FALLBACK_FA_BRAND = "گروه ساختمانی آرامش"
|
||||
FALLBACK_EN_BRAND = "Aramesh Construction Group"
|
||||
|
||||
|
||||
def _absolute(path: str) -> str:
|
||||
@@ -33,6 +40,29 @@ def _absolute(path: str) -> str:
|
||||
return f"{site}{path}" if site else path
|
||||
|
||||
|
||||
def _en_path(path: str) -> str:
|
||||
"""مسیر انگلیسی معادل یک مسیر فارسی — / و /en یکدیگرند."""
|
||||
return "/en" if path == "/" else f"/en{path}"
|
||||
|
||||
|
||||
def _fa_path(path: str) -> str:
|
||||
"""مسیر فارسی معادل یک مسیر انگلیسی."""
|
||||
if path == "/en":
|
||||
return "/"
|
||||
return path[3:] if path.startswith("/en/") else path
|
||||
|
||||
|
||||
def _alternates(path: str, lang: str) -> str:
|
||||
"""تگهای hreflang متقابل فارسی/انگلیسی برای head."""
|
||||
fa = path if lang == "fa" else _fa_path(path)
|
||||
en = _en_path(path) if lang == "fa" else path
|
||||
return (
|
||||
f'<link rel="alternate" hreflang="fa" href="{html.escape(_absolute(fa))}" />\n'
|
||||
f' <link rel="alternate" hreflang="en" href="{html.escape(_absolute(en))}" />\n'
|
||||
f' <link rel="alternate" hreflang="x-default" href="{html.escape(_absolute(fa))}" />'
|
||||
)
|
||||
|
||||
|
||||
def _read_index() -> str | None:
|
||||
try:
|
||||
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
|
||||
@@ -64,17 +94,19 @@ def _shell(
|
||||
title: str,
|
||||
description: str,
|
||||
path: str,
|
||||
lang: str = "fa",
|
||||
og_image: str = "",
|
||||
og_type: str = "website",
|
||||
jsonld: dict | None = None,
|
||||
noscript: str = "",
|
||||
) -> HTMLResponse:
|
||||
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
||||
title = title.strip() or "گروه ساختمانی آرامش"
|
||||
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
|
||||
description = description.strip()
|
||||
canonical = _absolute(path)
|
||||
image = _absolute(og_image)
|
||||
esc = html.escape
|
||||
alternates = _alternates(path, lang)
|
||||
|
||||
# تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
|
||||
tags = [
|
||||
@@ -83,6 +115,7 @@ def _shell(
|
||||
f'<meta property="og:description" content="{esc(description)}" />',
|
||||
f'<meta property="og:type" content="{esc(og_type)}" />',
|
||||
f'<meta property="og:url" content="{esc(canonical)}" />',
|
||||
f'<meta property="og:locale" content="{"en_US" if lang == "en" else "fa_IR"}" />',
|
||||
f'<meta name="twitter:card" content="summary_large_image" />',
|
||||
]
|
||||
if image:
|
||||
@@ -95,59 +128,114 @@ def _shell(
|
||||
raw = _read_index()
|
||||
if raw is None:
|
||||
# بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
|
||||
lang_attr = 'lang="en" dir="ltr"' if lang == "en" else 'lang="fa" dir="rtl"'
|
||||
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
|
||||
return HTMLResponse(
|
||||
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
|
||||
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
|
||||
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
|
||||
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>"
|
||||
)
|
||||
|
||||
if lang == "en":
|
||||
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض میشود
|
||||
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
|
||||
raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND)
|
||||
raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"')
|
||||
# description استاتیک حذف میشود تا با متای تزریقی تکراری نشود
|
||||
raw = re.sub(r'<meta name="description"[^>]*/>\s*', '', raw, count=1)
|
||||
# og:locale — در پوسته استاتیک بهروز میشود و از بلوک تزریقی حذف تا دوباره نوشته نشود
|
||||
og_locale = "en_US" if lang == "en" else "fa_IR"
|
||||
if re.search(r'<meta property="og:locale"', raw):
|
||||
raw = re.sub(
|
||||
r'(<meta property="og:locale" content=")[^"]*(")',
|
||||
lambda m: f'{m.group(1)}{og_locale}{m.group(2)}',
|
||||
raw,
|
||||
count=1,
|
||||
)
|
||||
tags = [t for t in tags if 'og:locale' not in t]
|
||||
block = "\n ".join(tags)
|
||||
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
|
||||
raw = raw.replace("</head>", f" {block}\n </head>", 1)
|
||||
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
||||
if noscript:
|
||||
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
||||
return HTMLResponse(raw)
|
||||
|
||||
|
||||
def _brand_name(db: Session) -> str:
|
||||
def _brand_name(db: Session, lang: str = "fa") -> str:
|
||||
brand = db.query(models.Brand).first()
|
||||
return brand.name if brand else "گروه ساختمانی آرامش"
|
||||
if brand is None:
|
||||
return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND
|
||||
if lang == "en":
|
||||
return brand.name_en or brand.name or FALLBACK_EN_BRAND
|
||||
return brand.name or FALLBACK_FA_BRAND
|
||||
|
||||
|
||||
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
|
||||
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
|
||||
|
||||
|
||||
def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
|
||||
"""عنوان و توضیح سئوی یک صفحه به زبان درخواستی — با برگشت به فارسی."""
|
||||
meta = _page_meta(db, page_key)
|
||||
if meta is None:
|
||||
return "", ""
|
||||
if lang == "en":
|
||||
return meta.title_en or meta.title or "", meta.description_en or meta.description or ""
|
||||
return meta.title or "", meta.description or ""
|
||||
|
||||
|
||||
# ---------- robots.txt و sitemap.xml ----------
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=Response)
|
||||
def robots(db: Session = Depends(get_db)):
|
||||
# پیشنمایش خصوصی — هیچ خزندهای نباید چیزی ذخیره کند
|
||||
if settings.preview_mode:
|
||||
return Response(
|
||||
content="User-agent: *\nDisallow: /\n",
|
||||
media_type="text/plain; charset=utf-8",
|
||||
)
|
||||
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
||||
if settings.site_url:
|
||||
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
||||
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
def _sitemap_entry(loc: str, fa: str, en: str) -> str:
|
||||
esc = html.escape
|
||||
return (
|
||||
f" <url><loc>{esc(_absolute(loc))}</loc>\n"
|
||||
f' <xhtml:link rel="alternate" hreflang="fa" href="{esc(_absolute(fa))}" />\n'
|
||||
f' <xhtml:link rel="alternate" hreflang="en" href="{esc(_absolute(en))}" />\n'
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sitemap.xml", response_class=Response)
|
||||
def sitemap(db: Session = Depends(get_db)):
|
||||
urls = list(STATIC_PATHS)
|
||||
urls += [
|
||||
# پیشنمایش خصوصی — نقشه سایت خالی تا هیچ نشانیای اعلام نشود
|
||||
if settings.preview_mode:
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n'
|
||||
)
|
||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
||||
fa_urls = list(STATIC_PATHS)
|
||||
fa_urls += [
|
||||
f"/blog/{a.slug}"
|
||||
for a in db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all()
|
||||
]
|
||||
urls += [
|
||||
fa_urls += [
|
||||
f"/projects/{p.slug}"
|
||||
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||
]
|
||||
items = "\n".join(
|
||||
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
|
||||
)
|
||||
items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls)
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
|
||||
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
|
||||
f"{items}\n</urlset>\n"
|
||||
)
|
||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
||||
@@ -163,10 +251,16 @@ def blog_list(db: Session = Depends(get_db)):
|
||||
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
|
||||
description=meta.description if meta else "",
|
||||
path="/blog",
|
||||
og_image=meta.image if meta else "",
|
||||
)
|
||||
|
||||
|
||||
def _article_pair(article: models.Article, lang: str) -> tuple[str, str]:
|
||||
"""عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی."""
|
||||
if lang == "en":
|
||||
return article.title_en or article.title, article.excerpt_en or article.excerpt
|
||||
return article.title, article.excerpt
|
||||
|
||||
|
||||
@router.get("/blog/{slug}", response_class=HTMLResponse)
|
||||
def blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
article = (
|
||||
@@ -179,22 +273,27 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
|
||||
|
||||
brand = _brand_name(db)
|
||||
title, excerpt = _article_pair(article, "fa")
|
||||
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
||||
jsonld = {
|
||||
jsonld: object = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": article.title,
|
||||
"description": article.excerpt,
|
||||
"headline": title,
|
||||
"description": excerpt,
|
||||
"image": _absolute(article.image),
|
||||
"inLanguage": "fa",
|
||||
"author": {"@type": "Organization", "name": brand},
|
||||
"publisher": {"@type": "Organization", "name": brand},
|
||||
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
||||
}
|
||||
noscript = f"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
# پرسشوپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایهای از دو اسکیما)
|
||||
faq_schema = _faq_jsonld(article, "fa")
|
||||
if faq_schema:
|
||||
jsonld = [jsonld, faq_schema]
|
||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
return _shell(
|
||||
title=f"{article.title} | {brand}",
|
||||
description=article.excerpt,
|
||||
title=f"{title} | {brand}",
|
||||
description=excerpt,
|
||||
path=f"/blog/{article.slug}",
|
||||
og_image=article.image,
|
||||
og_type="article",
|
||||
@@ -223,17 +322,158 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
||||
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
|
||||
|
||||
brand = _brand_name(db)
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": project.title,
|
||||
"description": project.summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "fa",
|
||||
}
|
||||
return _shell(
|
||||
title=f"{project.title} | {brand}",
|
||||
description=project.summary,
|
||||
path=f"/projects/{project.slug}",
|
||||
og_image=project.image,
|
||||
jsonld={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": project.title,
|
||||
"description": project.summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "fa",
|
||||
},
|
||||
jsonld=jsonld,
|
||||
)
|
||||
|
||||
|
||||
# ---------- صفحات انگلیسی (/en/...) ----------
|
||||
|
||||
|
||||
def _faq_jsonld(article: models.Article, lang: str) -> dict | None:
|
||||
"""اسکیمای FAQPage برای نتایج غنی گوگل — از پرسشوپاسخهای پایان مقاله.
|
||||
نسخه انگلیسی فقط وقتی ترجمه ثبت شده باشد جایگزین میشود."""
|
||||
faqs = article.faqs if isinstance(article.faqs, list) else []
|
||||
if lang == "en":
|
||||
faqs_en = article.faqs_en if isinstance(article.faqs_en, list) else None
|
||||
if faqs_en:
|
||||
faqs = faqs_en
|
||||
entities = [
|
||||
{"@type": "Question", "name": (f.get("question") or "").strip(),
|
||||
"acceptedAnswer": {"@type": "Answer", "text": (f.get("answer") or "").strip()}}
|
||||
for f in faqs
|
||||
if isinstance(f, dict) and (f.get("question") or "").strip()
|
||||
]
|
||||
if not entities:
|
||||
return None
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": entities,
|
||||
}
|
||||
|
||||
|
||||
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
||||
brand = _brand_name(db, "en")
|
||||
title, excerpt = _article_pair(article, "en")
|
||||
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
||||
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
||||
jsonld: object = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": title,
|
||||
"description": excerpt,
|
||||
"image": _absolute(article.image),
|
||||
"inLanguage": "en",
|
||||
"author": {"@type": "Organization", "name": brand},
|
||||
"publisher": {"@type": "Organization", "name": brand},
|
||||
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
||||
}
|
||||
faq_schema = _faq_jsonld(article, "en")
|
||||
if faq_schema:
|
||||
jsonld = [jsonld, faq_schema]
|
||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
return _shell(
|
||||
title=f"{title} | {brand}",
|
||||
description=excerpt,
|
||||
path=f"/en/blog/{article.slug}",
|
||||
lang="en",
|
||||
og_image=article.image,
|
||||
og_type="article",
|
||||
jsonld=jsonld,
|
||||
noscript=noscript,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/en", response_class=HTMLResponse)
|
||||
@router.get("/en/", response_class=HTMLResponse)
|
||||
def en_home(db: Session = Depends(get_db)):
|
||||
title, description = _meta_for(db, "home", "en")
|
||||
if not title:
|
||||
title = _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/blog", response_class=HTMLResponse)
|
||||
def en_blog_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "blog")
|
||||
title = meta.title_en or meta.title if meta else ""
|
||||
description = meta.description_en or meta.description if meta else ""
|
||||
if not title:
|
||||
title = "Blog | " + _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en/blog", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/blog/{slug}", response_class=HTMLResponse)
|
||||
def en_blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
article = (
|
||||
db.query(models.Article)
|
||||
.filter(models.Article.slug == slug, models.Article.status == "published")
|
||||
.first()
|
||||
)
|
||||
if article is None:
|
||||
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en")
|
||||
return _en_blog_detail(article, db)
|
||||
|
||||
|
||||
@router.get("/en/projects", response_class=HTMLResponse)
|
||||
def en_projects_list(db: Session = Depends(get_db)):
|
||||
meta = _page_meta(db, "projects")
|
||||
title = meta.title_en or meta.title if meta else ""
|
||||
description = meta.description_en or meta.description if meta else ""
|
||||
if not title:
|
||||
title = "Projects | " + _brand_name(db, "en")
|
||||
return _shell(title=title, description=description, path="/en/projects", lang="en")
|
||||
|
||||
|
||||
@router.get("/en/projects/{slug}", response_class=HTMLResponse)
|
||||
def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
||||
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||
if project is None:
|
||||
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en")
|
||||
|
||||
brand = _brand_name(db, "en")
|
||||
title = project.title_en or project.title
|
||||
summary = project.summary_en or project.summary
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": title,
|
||||
"description": summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "en",
|
||||
}
|
||||
return _shell(
|
||||
title=f"{title} | {brand}",
|
||||
description=summary,
|
||||
path=f"/en/projects/{project.slug}",
|
||||
lang="en",
|
||||
og_image=project.image,
|
||||
jsonld=jsonld,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/en/{path:path}", response_class=HTMLResponse)
|
||||
def en_page(path: str, db: Session = Depends(get_db)):
|
||||
"""بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی."""
|
||||
page_key = path.split("/")[0] if path else ""
|
||||
title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "")
|
||||
brand = _brand_name(db, "en")
|
||||
if not title:
|
||||
# عنوان پیشفرض از برچسب ناوبری انگلیسی همان صفحه
|
||||
nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None
|
||||
label = (nav.label_en or nav.label) if nav else ""
|
||||
title = f"{label} | {brand}" if label else brand
|
||||
return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""محیط Alembic — متادیتا از مدلهای SQLAlchemy میآید و آدرس دیتابیس از تنظیمات."""
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app import models # noqa: F401 — ثبت مدلها در متادیتا
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
return os.environ.get("DATABASE_URL", settings.database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=get_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""نقطه شروع زنجیره مهاجرتها — جداول پایه با create_all ساخته میشوند.
|
||||
|
||||
ساخت اولیه ۲۹ جدول بر عهده Base.metadata.create_all است (در CMD کانتینر پیش از
|
||||
این مهاجرت اجرا میشود و برای دیتابیسهای موجود بیاثر است). از این نسخه به بعد
|
||||
هر تغییر اسکیما باید یک revision واقعی Alembic باشد — نه create_all و نه
|
||||
تغییر دستی — تا مسیر ارتقا و بازگشت آزمایشپذیر بماند.
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -11,3 +11,11 @@ bcrypt==4.2.1
|
||||
email-validator==2.2.0
|
||||
boto3==1.35.0
|
||||
python-multipart==0.0.18
|
||||
# پاکسازی HTML سمت سرور (تیپتپ و فیلدهای غنی)
|
||||
nh3==0.2.20
|
||||
# بهینهسازی تصویر در زمان آپلود (تغییر اندازه/فشردهسازی مجدد)
|
||||
Pillow==11.1.0
|
||||
# متریکهای Prometheus برای مانیتورینگ (در /metrics داخلی)
|
||||
prometheus-fastapi-instrumentator==7.0.0
|
||||
# مهاجرتهای نسخهدار اسکیما
|
||||
alembic==1.14.0
|
||||
|
||||
@@ -74,8 +74,12 @@ def reset_rate_limiter():
|
||||
from app import rate_limit
|
||||
|
||||
rate_limit._failed.clear()
|
||||
rate_limit.contact_limiter._events.clear()
|
||||
rate_limit.newsletter_limiter._events.clear()
|
||||
yield
|
||||
rate_limit._failed.clear()
|
||||
rate_limit.contact_limiter._events.clear()
|
||||
rate_limit.newsletter_limiter._events.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
+399
-2
@@ -1,6 +1,7 @@
|
||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||
|
||||
import re
|
||||
import socket
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
@@ -8,12 +9,24 @@ DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
# ---------- عمومی ----------
|
||||
|
||||
|
||||
def test_health(client):
|
||||
res = client.get("/api/health")
|
||||
def test_live(client):
|
||||
"""liveness — پروسه زنده است، بدون وابستگی."""
|
||||
res = client.get("/api/live")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_deep(client):
|
||||
"""بررسی عمق — DB (SQLite در تست) سالم است؛ MinIO در محیط تست در دسترس نیست
|
||||
پس /api/health باید 503 و وضعیت degraded با جزئیات هر وابستگی بدهد."""
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code == 503
|
||||
body = res.json()
|
||||
assert body["status"] == "degraded"
|
||||
assert body["checks"]["database"] == "ok"
|
||||
assert body["checks"]["storage"] == "error"
|
||||
|
||||
|
||||
def test_site_hides_draft_articles(client):
|
||||
res = client.get("/api/site")
|
||||
assert res.status_code == 200
|
||||
@@ -180,3 +193,387 @@ def test_head_not_injected_for_draft(client):
|
||||
def test_head_replaces_title_once(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert len(re.findall(r"<title>", res.text)) == 1
|
||||
|
||||
|
||||
# ---------- دوزبانه ----------
|
||||
|
||||
|
||||
def test_bilingual_project_and_en_seo_shell(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/projects",
|
||||
json={
|
||||
"slug": "bilingual-tower",
|
||||
"title": "برج دوزبانه",
|
||||
"title_en": "Bilingual Tower",
|
||||
"summary": "خلاصه فارسی",
|
||||
"summary_en": "English summary",
|
||||
"body": [],
|
||||
"body_en": ["English paragraph"],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
pid = created.json()["id"]
|
||||
|
||||
# GET /api/site هر دو زبان را برمیگرداند
|
||||
project = next(p for p in client.get("/api/site").json()["projects"] if p["slug"] == "bilingual-tower")
|
||||
assert project["title"] == "برج دوزبانه"
|
||||
assert project["title_en"] == "Bilingual Tower"
|
||||
assert project["body_en"] == ["English paragraph"]
|
||||
|
||||
# پوسته سئوی انگلیسی — جهت و متای انگلیسی و hreflang متقابل
|
||||
res = client.get("/en/projects/bilingual-tower")
|
||||
assert res.status_code == 200
|
||||
assert 'lang="en" dir="ltr"' in res.text
|
||||
assert "Bilingual Tower" in res.text
|
||||
assert "English summary" in res.text
|
||||
assert 'hreflang="fa"' in res.text
|
||||
assert "https://test.example/projects/bilingual-tower" in res.text
|
||||
assert "https://test.example/en/projects/bilingual-tower" in res.text
|
||||
|
||||
# sitemap دو زبانه با alternates
|
||||
sm = client.get("/sitemap.xml").text
|
||||
assert "xmlns:xhtml" in sm
|
||||
assert 'hreflang="en"' in sm
|
||||
assert "/en/projects/bilingual-tower" in sm
|
||||
|
||||
# پوسته فارسی هم alternates دارد
|
||||
assert 'hreflang="en"' in client.get("/blog/published-post").text
|
||||
|
||||
# صفحه استاتیک انگلیسی از مسیر catch-all سرو میشود
|
||||
res = client.get("/en/about")
|
||||
assert res.status_code == 200
|
||||
assert 'lang="en"' in res.text
|
||||
|
||||
assert auth_client.delete(f"/api/admin/projects/{pid}").status_code == 204
|
||||
assert "bilingual-tower" not in client.get("/sitemap.xml").text
|
||||
|
||||
|
||||
def test_bilingual_article_en_shell(auth_client, client):
|
||||
articles = auth_client.get("/api/admin/articles").json()
|
||||
article = next(a for a in articles if a["slug"] == "published-post")
|
||||
res = auth_client.patch(
|
||||
f"/api/admin/articles/{article['id']}",
|
||||
json={
|
||||
"title_en": "Published EN",
|
||||
"excerpt_en": "English excerpt",
|
||||
"body_en": DOC,
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
res = client.get("/en/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "Published EN" in res.text
|
||||
assert "English excerpt" in res.text
|
||||
assert '"inLanguage": "en"' in res.text
|
||||
|
||||
|
||||
def test_en_backfill_fills_only_empty_fields(auth_client, client):
|
||||
"""backfill ترجمههای خالی را پر میکند و مقدار ثبتشده را بازنویسی نمیکند."""
|
||||
from app.migrations import backfill_en_content
|
||||
|
||||
backfill_en_content()
|
||||
brand = client.get("/api/site").json()["brand"]
|
||||
assert brand["name_en"] == "Aramesh Construction Group"
|
||||
|
||||
# مقدار دستیِ ادمین محترم شمرده میشود
|
||||
res = auth_client.patch(f"/api/admin/brand/{brand['id']}", json={"name_en": "Custom EN Name"})
|
||||
assert res.status_code == 200
|
||||
backfill_en_content()
|
||||
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
|
||||
|
||||
|
||||
# ---------- محافظت فرمها و پاکسازی ----------
|
||||
|
||||
|
||||
def test_contact_honeypot_saves_nothing(client, auth_client):
|
||||
"""دامنهٔ پنهان پرشده یعنی ربات — پاسخ موفقِ قلابی، بدون ذخیره."""
|
||||
res = client.post(
|
||||
"/api/contact",
|
||||
json={"name": "ربات تستی", "phone": "09120000000", "website": "spam"},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["id"] == 0 # رکورد قلابی — در دیتابیس نیست
|
||||
assert auth_client.get("/api/admin/messages").json() == []
|
||||
|
||||
|
||||
def test_contact_rate_limited(client, auth_client):
|
||||
"""ارسال ششم در پنجرهٔ ۱۵ دقیقهای رد میشود (۵ مجاز برای هر IP)."""
|
||||
for _ in range(5):
|
||||
res = client.post(
|
||||
"/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"}
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
res = client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
|
||||
assert res.status_code == 429
|
||||
assert "Retry-After" in res.headers
|
||||
|
||||
|
||||
def test_newsletter_honeypot_saves_nothing(client, auth_client):
|
||||
res = client.post(
|
||||
"/api/newsletter", json={"email": "bot@example.com", "website": "spam"}
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["id"] == 0
|
||||
assert auth_client.get("/api/admin/subscribers").json() == []
|
||||
|
||||
|
||||
def test_message_and_subscriber_csv_export(auth_client):
|
||||
auth_client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
|
||||
res = auth_client.get("/api/admin/messages/export.csv")
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("text/csv")
|
||||
assert "کاربر تماس" in res.text
|
||||
|
||||
auth_client.post("/api/newsletter", json={"email": "sale@example.com"})
|
||||
res = auth_client.get("/api/admin/subscribers/export.csv")
|
||||
assert res.status_code == 200
|
||||
assert "sale@example.com" in res.text
|
||||
|
||||
|
||||
def test_article_body_url_sanitization(auth_client):
|
||||
"""اسکیمهای خطرناک در href/src سند TipTap در زمان ذخیره حذف میشوند."""
|
||||
res = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={
|
||||
"slug": "sanitized-post",
|
||||
"title": "مقاله پاکسازیشده",
|
||||
"status": "published",
|
||||
"body": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{"type": "link", "attrs": {"href": "javascript:alert(1)"}},
|
||||
],
|
||||
"text": "لینک خطرناک",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
body = res.json()["body"]
|
||||
marks = body["content"][0]["content"][0].get("marks", [])
|
||||
assert all("href" not in m.get("attrs", {}) for m in marks)
|
||||
|
||||
|
||||
# ---------- نامگذاری تصاویر، درونریزی و پرسشوپاسخ مقاله ----------
|
||||
|
||||
|
||||
class _FakeS3:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.objects = {}
|
||||
|
||||
def put_object(self, **kwargs):
|
||||
self.objects[kwargs["Key"]] = kwargs["Body"]
|
||||
|
||||
|
||||
def _mock_storage(monkeypatch):
|
||||
"""MinIO در تست در دسترس نیست — کلاینت S3 و DNS عمومی جعل میشوند."""
|
||||
fake = _FakeS3()
|
||||
monkeypatch.setattr("app.api.admin.boto3.client", lambda *a, **k: fake)
|
||||
monkeypatch.setattr(
|
||||
"app.api.admin.socket.getaddrinfo",
|
||||
lambda host, port, *a, **k: [(socket.AF_INET, None, None, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (200, 120, 60)).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_upload_with_readable_name(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("raw name.png", _png_bytes(), "image/png")},
|
||||
data={"name": "برج الهیه tower!"},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
key = res.json()["key"]
|
||||
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + پسوند تصویر
|
||||
assert "برج-الهیه-tower" in key
|
||||
assert key.endswith(".png")
|
||||
assert "!" not in key
|
||||
|
||||
|
||||
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("Elahieh Tower.jpg", _png_bytes(), "image/jpeg")},
|
||||
data={"name": ""},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert "Elahieh-Tower" in res.json()["key"]
|
||||
|
||||
|
||||
def test_import_image_rejects_internal_host(auth_client):
|
||||
# SSRF — میزبان حلقهمحلی/خصوصی رد میشود پیش از هر درخواست شبکه
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "http://127.0.0.1:9000/x.png"})
|
||||
assert res.status_code == 400
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "http://192.168.1.5/x.png"})
|
||||
assert res.status_code == 400
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "file:///etc/passwd"})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_import_image_rejects_bad_content_type(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
import io as _io
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Type": "text/html"}
|
||||
|
||||
def read(self, n):
|
||||
return b"<html></html>"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", fake_urlopen)
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://example.com/page"})
|
||||
assert res.status_code == 415
|
||||
|
||||
|
||||
def test_import_image_happy_path(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Type": "image/png"}
|
||||
|
||||
def read(self, n):
|
||||
return _png_bytes()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", lambda request, timeout: FakeResponse())
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["url"].startswith("/media/")
|
||||
assert "Elahieh-pic" in res.json()["key"]
|
||||
|
||||
|
||||
def test_article_persists_image_meta_and_faqs(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={"slug": "meta-article", "title": "متادیتا", "body": DOC},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
article_id = created.json()["id"]
|
||||
|
||||
faqs = [{"question": "هزینه چقدر است؟", "answer": "بستگی به پروژه دارد."}]
|
||||
res = auth_client.patch(
|
||||
f"/api/admin/articles/{article_id}",
|
||||
json={
|
||||
"image_alt": "نمای برج الهیه",
|
||||
"image_title": "برج آرامش",
|
||||
"faqs": faqs,
|
||||
"faqs_en": [{"question": "What is the cost?", "answer": "Depends on the project."}],
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["image_alt"] == "نمای برج الهیه"
|
||||
assert res.json()["image_title"] == "برج آرامش"
|
||||
assert res.json()["faqs"] == faqs
|
||||
|
||||
# انتشار و سپس خروجی عمومی — همین مقادیر برمیگردد
|
||||
assert auth_client.patch(f"/api/admin/articles/{article_id}", json={"status": "published"}).status_code == 200
|
||||
public = client.get("/api/articles/meta-article").json()
|
||||
assert public["image_alt"] == "نمای برج الهیه"
|
||||
assert public["faqs"] == faqs
|
||||
|
||||
assert auth_client.delete(f"/api/admin/articles/{article_id}").status_code == 204
|
||||
|
||||
|
||||
def test_blog_shell_includes_faqpage_jsonld(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={
|
||||
"slug": "faq-article",
|
||||
"title": "مقاله پرسشوپاسخ",
|
||||
"body": DOC,
|
||||
"status": "published",
|
||||
"faqs": [{"question": "زمان ساخت؟", "answer": "۳۰ ماه."}],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
res = client.get("/blog/faq-article")
|
||||
assert res.status_code == 200
|
||||
assert "FAQPage" in res.text
|
||||
assert "زمان ساخت؟" in res.text
|
||||
|
||||
# نسخه انگلیسی بدون ترجمه → همان فارسی؛ با ترجمه → انگلیسی
|
||||
en = client.get("/en/blog/faq-article").text
|
||||
assert "FAQPage" in en
|
||||
article_id = created.json()["id"]
|
||||
auth_client.patch(
|
||||
f"/api/admin/articles/{article_id}",
|
||||
json={"faqs_en": [{"question": "How long?", "answer": "Thirty months."}]},
|
||||
)
|
||||
en = client.get("/en/blog/faq-article").text
|
||||
assert "How long?" in en
|
||||
assert "زمان ساخت؟" not in en
|
||||
|
||||
auth_client.delete(f"/api/admin/articles/{article_id}")
|
||||
|
||||
|
||||
def test_blog_shell_without_faqs_has_no_faqpage(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "FAQPage" not in res.text
|
||||
|
||||
|
||||
# ---------- پیشنمایش خصوصی (برای انتشار عمومی خاموش میشود) ----------
|
||||
|
||||
|
||||
def test_preview_mode_blocks_robots_and_sitemap(client, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "preview_mode", True)
|
||||
try:
|
||||
robots = client.get("/robots.txt")
|
||||
assert robots.status_code == 200
|
||||
assert "Disallow: /" in robots.text
|
||||
assert "Sitemap:" not in robots.text
|
||||
|
||||
sitemap = client.get("/sitemap.xml")
|
||||
assert sitemap.status_code == 200
|
||||
assert "published-post" not in sitemap.text
|
||||
|
||||
# لایه دوم: هدر noindex روی همه پاسخها
|
||||
assert "noindex" in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||
finally:
|
||||
monkeypatch.setattr(settings, "preview_mode", False)
|
||||
|
||||
|
||||
def test_public_mode_allows_indexing(client):
|
||||
robots = client.get("/robots.txt").text
|
||||
assert "Disallow: /admin" in robots
|
||||
assert "Sitemap:" in robots
|
||||
assert "noindex" not in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# کانتکست بیلد باریک
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
test_pure.py
|
||||
@@ -0,0 +1,28 @@
|
||||
# سرویس چتبات — این مقادیر را در .env ریشه پروژه بگذارید (نه اینجا)
|
||||
# این فایل فقط مستندسازی متغیرهاست.
|
||||
|
||||
# --- اجباری ---
|
||||
# دیتابیس سایت (فقط خواندن) — در داکر از docker-compose.yml تزریق میشود
|
||||
# DATABASE_URL=postgresql+psycopg://aramesh:PASSWORD@db:5432/arameshplaza
|
||||
|
||||
# LLM سازگار با OpenAI — بدون این چتبات پاسخ نمیدهد
|
||||
# مثال سرور داخلی: LLM_BASE_URL=http://172.16.22.21:4460/v1
|
||||
# مثال سرویس ابری: LLM_BASE_URL=https://api.example.com/v1
|
||||
LLM_BASE_URL=
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=openai/gpt-oss-120b
|
||||
|
||||
# --- اختیاری ---
|
||||
# توکن محافظت از POST /chat/reindex — اگر خالی باشد مسیر ۴۰۳ میدهد
|
||||
CHAT_REINDEX_TOKEN=
|
||||
# ریرنکر را روی سرور کمرم خاموش کنید (RERANKER_ENABLED=false)
|
||||
RERANKER_ENABLED=true
|
||||
# وزن جستوجوی معنایی در ترکیب نمرهها (۰ تا ۱) — پیشفرض ۰.۵
|
||||
ALPHA_DENSE=0.5
|
||||
# تعداد سند نهایی به LLM — پیشفرض ۳
|
||||
BEST_OF_K=3
|
||||
# بازسازی خودکار ایندکس پس از تغییر محتوا (ثانیه، ۰ = خاموش)
|
||||
INDEX_REFRESH_SECONDS=600
|
||||
# محدودیت نرخ: حداکثر پیام در پنجره زمانی (پیشفرض ۲۰ پیام در ۳۰۰ ثانیه)
|
||||
RATE_LIMIT_MESSAGES=20
|
||||
RATE_LIMIT_WINDOW=300
|
||||
@@ -0,0 +1,29 @@
|
||||
# سرویس چتبات — پایتون + مدلهای بازیابی (CPU)
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
# همهٔ وابستگیهای سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند —
|
||||
# بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابلاعتماد نیست
|
||||
COPY requirements.txt /srv/chatbot/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
|
||||
|
||||
COPY . /srv/chatbot/
|
||||
|
||||
# مدل امبدینگ/ریرنکر در /data کش میشود (والیوم داکر) — بردارها در pgvector میروند و فایل ایندکس جدا وجود ندارد
|
||||
ENV HF_HOME=/data/hf \
|
||||
HF_CACHE_DIR=/data/hf
|
||||
VOLUME /data
|
||||
|
||||
# کاربر بدون دسترسی root — فقط /data باید نوشتنی باشد (کش مدلها)
|
||||
RUN useradd --system --create-home appuser \
|
||||
&& mkdir -p /data \
|
||||
&& chown -R appuser:appuser /srv/chatbot /data
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/chat/healthz')" || exit 1
|
||||
|
||||
CMD ["uvicorn", "chatbot.app:api", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
|
||||
@@ -0,0 +1,121 @@
|
||||
# دستیار هوشمند سایت (چتبات)
|
||||
|
||||
سرویس گفتگوی محتوای سایت «گروه ساختمانی آرامش» — بازدیدکننده به فارسی سؤال میپرسد و
|
||||
دستیار **فقط از روی محتوای خود سایت** (مقالهها، پروژهها، خدمات، سؤالات متداول، اطلاعات تماس و…)
|
||||
پاسخ میدهد؛ اگر پاسخ در محتوا نباشد، بهجای ساخت جواب، صادقانه اعلام میکند.
|
||||
|
||||
این سرویس پورت خط لولهٔ پروژهٔ `llm-srerver-chatbot` (نسخه v13) با چهار تغییر اصلی برای این سایت است:
|
||||
|
||||
1. **پایگاه sparse بهجای SPLADE از BM25 استفاده میکند** — SPLADE انگلیسیمحور است و روی
|
||||
فارسی عملاً بیفایده؛ BM25 با نرمالسازی فارسی (ی/ک عربی، اعراب، ارقام، نیمفاصله) جایگزین شده است.
|
||||
2. **ذخیرهساز برداری pgvector است، نه FAISS** — بردارها در یک دیتابیس جدا (`aramesh_chatbot`)
|
||||
روی همان نمونه Postgres نگهداری میشوند؛ همگامسازی افزایشی است (فقط سطرهای جدید/تغییریافته
|
||||
امبد میشوند)، بکاپگیری با خود دیتابیس است و فایل ایندکس جدا وجود ندارد.
|
||||
3. **تأخیر کمتر** — مسیریابی `alpha` با LLM و گسترش چندپرسشی (multi-query) بهصورت پیشفرض
|
||||
خاموشاند (با متغیر محیطی قابل فعالسازی)؛ بازنویسی پرسش فقط وقتی تاریخچه گفتگو وجود دارد اجرا میشود.
|
||||
4. **استریم واقعی توکنبهتوکن** با `astream_events` بهجای دیف پیامهای کامل.
|
||||
|
||||
## خط لوله هر پیام
|
||||
|
||||
```
|
||||
پرسش کاربر
|
||||
│
|
||||
├─[اگر تاریخچه هست] بازنویسی پرسش با LLM (حل ضمیرها و اشارهها: «اون دومی» → موضوع واقعی)
|
||||
│
|
||||
├─ بازیابی ترکیبی: pgvector (معنایی، کسینوسی روی HNSW — MiniLM چندزبانه)
|
||||
│ + BM25 (کلیدواژه، متن نرمالشده فارسی)
|
||||
│ نمرهها min-max → ترکیب α×معنایی + (1-α)×کلیدواژه
|
||||
│
|
||||
├─ برش خوشهای (K-Means K=2 + silhouette ≥ 0.55) — مرز طبیعی کیفیت
|
||||
│
|
||||
├─ ریرنک با BAAI/bge-reranker-v2-m3 → نگهداشتن ۳ سند برتر
|
||||
│
|
||||
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
|
||||
```
|
||||
|
||||
ایندکس از محتوای PostgreSQL سایت ساخته میشود (`articles` منتشرشده، `projects`، `services`،
|
||||
`faqs`، `brand`، `offices`، `investment_models`، `process_steps`، `why_us`، `team`،
|
||||
`testimonials`، `timeline`، `story`). متنهای بلند به قطعات ~۷۰۰ نویسهای همپوشان برش میخورند.
|
||||
|
||||
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
|
||||
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
|
||||
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار و `content_hash` دارد و همگامسازی
|
||||
فقط سطرهای جدید/تغییریافته را امبد میکند. دیتابیس و جدول در شروع سرویس خودکار ساخته میشوند.
|
||||
- **بازسازی خودکار**: یک حلقهٔ پسزمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
|
||||
میکند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام میشود
|
||||
(امبد اجباری همه: `POST /chat/reindex`).
|
||||
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگگراف) — با ریاستارت پاک میشود.
|
||||
|
||||
## متغیرهای محیطی (در `.env` ریشه پروژه)
|
||||
|
||||
| متغیر | پیشفرض | توضیح |
|
||||
|---|---|---|
|
||||
| `LLM_BASE_URL` | خالی | **اجباری** — نشانی سرویس LLM سازگار با OpenAI. بدون آن چتبات بالا میآید ولی پاسخ نمیدهد |
|
||||
| `LLM_API_KEY` | خالی | کلید API (برای سرور داخلی `not-needed` بگذارید) |
|
||||
| `LLM_MODEL` | `openai/gpt-oss-120b` | نام مدل |
|
||||
| `CHAT_REINDEX_TOKEN` | خالی | توکن `POST /chat/reindex` — اگر خالی باشد آن مسیر ۴۰۳ میدهد |
|
||||
| `CHATBOT_DB_NAME` | `aramesh_chatbot` | نام دیتابیس اختصاصی چتبات روی همان نمونه Postgres (بردارها اینجا میروند) |
|
||||
| `EMBEDDING_DIM` | `384` | بعد بردار مدل امبدینگ — با تعویض مدل امبدینگ باید هماهنگ شود |
|
||||
| `RERANKER_ENABLED` | `true` | روی سرور کمرم (زیر ~۴GB) `false` بگذارید |
|
||||
| `ALPHA_DENSE` | `0.5` | وزن جستوجوی معنایی در ترکیب نمرهها (۰ تا ۱) |
|
||||
| `BEST_OF_K` | `3` | تعداد سند نهایی که به LLM میرود |
|
||||
| `INDEX_REFRESH_SECONDS` | `600` | بازسازی خودکار ایندکس پس از تغییر محتوا (۰ = خاموش) |
|
||||
| `RATE_LIMIT_MESSAGES` / `RATE_LIMIT_WINDOW` | `20` / `300` | سقف پیام هر IP در پنجره زمانی (ثانیه) |
|
||||
|
||||
`DATABASE_URL` در داکر از `docker-compose.yml` تزریق میشود — دیتابیس سایت فقط خوانده
|
||||
میشود و نوشتن (بردارها) فقط در دیتابیس جدا `aramesh_chatbot` انجام میگیرد.
|
||||
|
||||
## پیشنیاز pgvector
|
||||
|
||||
سرویس `db` استک از ایمیج `pgvector/pgvector:pg17` استفاده میکند (همان postgres:17 +
|
||||
افزونهٔ vector). اگر Postgres سایت روی نمونهٔ دیگری است، باید افزونهٔ pgvector روی آن
|
||||
فعال باشد؛ در غیر این صورت شروع چتبات با خطای واضح متوقف میشود.
|
||||
|
||||
## مسیرها
|
||||
|
||||
| مسیر | توضیح |
|
||||
|---|---|
|
||||
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکنبهتوکن پاسخ (`{"content": …}` و در پایان `{"done": true}`) |
|
||||
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
|
||||
| `GET /chat/eval?message=…` | مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده با thread تازه |
|
||||
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
|
||||
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
|
||||
|
||||
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
|
||||
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
|
||||
|
||||
## اجرا
|
||||
|
||||
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
|
||||
|
||||
```bash
|
||||
# در .env ریشه:
|
||||
LLM_BASE_URL=http://172.16.22.21:4460/v1 # یا هر سرویس سازگار با OpenAI
|
||||
LLM_API_KEY=not-needed
|
||||
```
|
||||
|
||||
اولین بوت مدلها را دانلود میکند (~۱.۵GB: امبدینگ + ریرنکر) و در والیوم
|
||||
`chatbot_cache` کش میشود؛ بوتهای بعدی چند ثانیه طول میکشد.
|
||||
|
||||
اجرای محلی بدون داکر:
|
||||
|
||||
```bash
|
||||
cd chatbot
|
||||
python -m venv .venv && source .venv/bin/activate # ویندوز: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
export DATABASE_URL="postgresql+psycopg://aramesh:PASS@localhost:5432/arameshplaza"
|
||||
export LLM_BASE_URL="http://…" LLM_MODEL="openai/gpt-oss-120b"
|
||||
uvicorn chatbot.app:api --port 8000 # از ریشه پوشه والد
|
||||
```
|
||||
|
||||
## منابع مورد نیاز
|
||||
|
||||
- **رم**: با ریرنکر ~۴GB (مدل ۵۶۸M پارامتری روی CPU)؛ بدون ریرنکر ~۲GB
|
||||
- **CPU**: امبدینگ و ریرنک روی CPU اجرا میشوند؛ GPU لازم نیست
|
||||
- **شبکه**: دسترسی به PostgreSQL و به نشانی `LLM_BASE_URL`
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- دیتابیس سایت فقط **خواندن**؛ نوشتن فقط در دیتابیس اختصاصی `aramesh_chatbot` انجام میشود
|
||||
- محدودسازی نرخ per-IP فعال است (پیشفرض ۲۰ پیام در ۵ دقیقه)
|
||||
- CORS باز است ولی کوکی/اعتبارنامهای رد و بدل نمیشود؛ در تولید همهچیز هممبدأ (پشت Traefik) است
|
||||
@@ -0,0 +1 @@
|
||||
"""سرویس چتبات سایت آرامش — بازیابی ترکیبی روی محتوای PostgreSQL."""
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
"""سرویس چتبات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from . import content as content_mod
|
||||
from . import pipeline
|
||||
from . import vectorstore
|
||||
from .config import settings
|
||||
from .indexing import INDEX, sync_index
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||
)
|
||||
log = logging.getLogger("chatbot")
|
||||
|
||||
|
||||
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
|
||||
|
||||
_refresh_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
await asyncio.to_thread(sync_index)
|
||||
except Exception as e:
|
||||
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
|
||||
global _refresh_task
|
||||
_refresh_task = asyncio.create_task(_refresh_loop())
|
||||
yield
|
||||
_refresh_task.cancel()
|
||||
|
||||
|
||||
async def _refresh_loop() -> None:
|
||||
interval = settings.index_refresh_seconds
|
||||
if interval <= 0:
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
# همگامسازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد میشوند
|
||||
await asyncio.to_thread(sync_index)
|
||||
except Exception as e:
|
||||
log.warning("Background index refresh failed: %s", e)
|
||||
|
||||
|
||||
api = FastAPI(
|
||||
title="Aramesh Site Chatbot",
|
||||
version="1.0.0",
|
||||
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ریرنکر BGE",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
api.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
|
||||
|
||||
_hits: dict[str, deque] = defaultdict(deque)
|
||||
_last_sweep = 0.0
|
||||
|
||||
|
||||
def _rate_limited(ip: str) -> bool:
|
||||
global _last_sweep
|
||||
now = time.time()
|
||||
# پاکسازی دورهای کلیدهای بیمصرف تا دیکشنری بینهایت بزرگ نشود
|
||||
if now - _last_sweep > settings.rate_limit_window:
|
||||
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
|
||||
for k in stale:
|
||||
_hits.pop(k, None)
|
||||
_last_sweep = now
|
||||
window = _hits[ip]
|
||||
while window and now - window[0] > settings.rate_limit_window:
|
||||
window.popleft()
|
||||
if len(window) >= settings.rate_limit_messages:
|
||||
return True
|
||||
window.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request: Request | None) -> str:
|
||||
if request is None:
|
||||
return "?"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
|
||||
|
||||
|
||||
# --- کمکیهای SSE ---
|
||||
|
||||
def _sse(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _chunk_text(chunk) -> str:
|
||||
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخشها)."""
|
||||
content = getattr(chunk, "content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
return ""
|
||||
|
||||
|
||||
async def _stream_answer(message: str, thread_id: str):
|
||||
if not INDEX.ready:
|
||||
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
|
||||
return
|
||||
if pipeline.get_llm() is None:
|
||||
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
|
||||
return
|
||||
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
inputs = {"messages": [HumanMessage(content=message)]}
|
||||
try:
|
||||
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
|
||||
# فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
|
||||
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
|
||||
text = _chunk_text(event["data"]["chunk"])
|
||||
if text:
|
||||
yield _sse({"content": text, "done": False})
|
||||
yield _sse({"content": "", "done": True})
|
||||
except Exception as e:
|
||||
log.exception("Streaming failed")
|
||||
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
|
||||
|
||||
|
||||
# --- مسیرها ---
|
||||
|
||||
@api.get("/chat/healthz")
|
||||
async def healthz():
|
||||
return {
|
||||
"status": "ok",
|
||||
"index_ready": INDEX.ready,
|
||||
"documents": len(INDEX.docs),
|
||||
"vectors": await asyncio.to_thread(vectorstore.vector_count),
|
||||
"llm_configured": bool(settings.llm_base_url),
|
||||
"reranker_enabled": settings.reranker_enabled,
|
||||
}
|
||||
|
||||
|
||||
@api.get("/chat/suggestions")
|
||||
async def suggestions():
|
||||
return {"questions": _get_suggestions()}
|
||||
|
||||
|
||||
@api.get("/chat/eval")
|
||||
async def eval_question(message: str = Query(...)):
|
||||
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده، با thread تازه."""
|
||||
if not INDEX.ready:
|
||||
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
|
||||
if pipeline.get_llm() is None:
|
||||
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
|
||||
|
||||
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
inputs = {"messages": [HumanMessage(content=message)]}
|
||||
final_state = await pipeline.graph.ainvoke(inputs, config=config)
|
||||
|
||||
answer = ""
|
||||
for msg in reversed(final_state.get("messages", [])):
|
||||
if isinstance(msg, AIMessage):
|
||||
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||
break
|
||||
return {
|
||||
"question": message,
|
||||
"contextualized_query": final_state.get("contextualized_query") or message,
|
||||
"answer": answer,
|
||||
"contexts": final_state.get("documents", []),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
|
||||
@api.post("/chat/reindex")
|
||||
async def reindex(request: Request):
|
||||
"""همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
|
||||
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
|
||||
raise HTTPException(status_code=403, detail="invalid reindex token")
|
||||
stats = await asyncio.to_thread(sync_index, True)
|
||||
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
|
||||
|
||||
|
||||
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval میسازد
|
||||
|
||||
@api.get("/chat/{scope}/stream")
|
||||
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||
return await stream_chat(message=message, thread_id=thread_id, request=request)
|
||||
|
||||
|
||||
@api.get("/chat/{scope}/eval")
|
||||
async def eval_scoped(scope: str, message: str = Query(...)):
|
||||
return await eval_question(message=message)
|
||||
|
||||
|
||||
@api.get("/chat/{scope}/suggestions")
|
||||
async def suggestions_scoped(scope: str):
|
||||
return {"questions": _get_suggestions()}
|
||||
|
||||
|
||||
@api.get("/chat/stream")
|
||||
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||
"""استریم توکنبهتوکن پاسخ با SSE."""
|
||||
if _rate_limited(_client_ip(request)):
|
||||
raise HTTPException(status_code=429, detail="تعداد پیامهای شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
|
||||
|
||||
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
|
||||
|
||||
async def generate():
|
||||
async for chunk in _stream_answer(message, thread_id):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# --- پیشنهاد پرسشها (کششده) ---
|
||||
|
||||
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
|
||||
|
||||
|
||||
def _get_suggestions() -> list[str]:
|
||||
global _suggestions_cache
|
||||
now = time.time()
|
||||
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
|
||||
return _suggestions_cache[1]
|
||||
|
||||
questions: list[str] = []
|
||||
if INDEX.ready:
|
||||
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
|
||||
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
|
||||
model = pipeline.get_llm()
|
||||
if model and (faqs or titles):
|
||||
material = "\n".join([d["text"][:200] for d in faqs] + titles)
|
||||
try:
|
||||
prompt = (
|
||||
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
|
||||
"احتمالاً میپرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
|
||||
'فقط آرایه JSON رشتهای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
|
||||
f"محتوا:\n{material[:2000]}"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)])
|
||||
text = resp.content if isinstance(resp.content, str) else ""
|
||||
if text.startswith("```"):
|
||||
text = text.split("```")[1]
|
||||
text = text[4:] if text.startswith("json") else text
|
||||
import re
|
||||
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||
if match:
|
||||
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
|
||||
except Exception as e:
|
||||
log.warning("Suggestion generation failed: %s", e)
|
||||
if not questions and faqs:
|
||||
# جایگزین: خود پرسشهای متداول
|
||||
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
|
||||
|
||||
_suggestions_cache = (now, questions)
|
||||
return questions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(api, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""تنظیمات سرویس چتبات — از متغیرهای محیطی یا فایل .env خوانده میشود."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# دیتابیس سایت (فقط خواندن) — همان DATABASE_URL بکاند
|
||||
database_url: str = "postgresql+psycopg://aramesh:aramesh@localhost:5432/arameshplaza"
|
||||
# دیتابیس اختصاصی چتبات روی همان نمونه Postgres — بردارها اینجا نوشته میشوند
|
||||
# تا دیتابیس سایت فقطخواندنی بماند؛ در شروع سرویس خودکار ساخته میشود
|
||||
chatbot_db_name: str = "aramesh_chatbot"
|
||||
|
||||
# LLM سازگار با OpenAI — اجباری؛ بدون آن پاسخگویی غیرفعال است
|
||||
llm_base_url: str = ""
|
||||
llm_api_key: str = ""
|
||||
llm_model: str = "openai/gpt-oss-120b"
|
||||
|
||||
# مدلهای بازیابی
|
||||
embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||
# بعد بردار خروجی مدل امبدینگ (MiniLM-L12 = 384) — با تغییر مدل باید عوض شود
|
||||
embedding_dim: int = 384
|
||||
reranker_model: str = "BAAI/bge-reranker-v2-m3"
|
||||
reranker_enabled: bool = True
|
||||
reranker_device: str = "cpu"
|
||||
|
||||
# وزن جستوجوی معنایی در ترکیب نمرهها (۰ = فقط کلیدواژه، ۱ = فقط معنایی)
|
||||
alpha_dense: float = 0.5
|
||||
# تعداد سند نهایی که به LLM داده میشود
|
||||
best_of_k: int = 3
|
||||
# تعداد نتیجه هر پایگاه (معنایی و کلیدواژه) برای هر پرسوجو
|
||||
retrieve_k: int = 12
|
||||
# بازنویسی پرسوجو با تاریخچه گفتگو (فقط وقتی تاریخچه هست صدا زده میشود)
|
||||
contextualize_enabled: bool = True
|
||||
# گسترش پرسوجو به چند نوع — برای پیکره کوچک پیشفرض خاموش (تأخیر کمتر)
|
||||
query_expansion: bool = False
|
||||
|
||||
# مسیر کش مدلها (در داکر روی والیوم /data سوار میشود)
|
||||
hf_cache_dir: str = "/data/hf"
|
||||
|
||||
# محدودسازی نرخ درخواست هر IP (تعداد پیام در پنجره زمانی به ثانیه)
|
||||
rate_limit_messages: int = 20
|
||||
rate_limit_window: int = 300
|
||||
|
||||
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار میکند
|
||||
reindex_token: str = ""
|
||||
|
||||
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
|
||||
suggestions_ttl: int = 600
|
||||
site_context_ttl: int = 120
|
||||
|
||||
# بازسازی خودکار ایندکس وقتی محتوای دیتابیس عوض شود (۰ = خاموش)
|
||||
index_refresh_seconds: int = 600
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,315 @@
|
||||
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
|
||||
|
||||
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
|
||||
پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
|
||||
پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .persian import normalize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# URL پایه صفحات — چتبات مسیر نسبی میدهد؛ فرانتاند روی همان دامنه است
|
||||
_ARTICLE_URL = "/blog/{slug}"
|
||||
_PROJECT_URL = "/projects/{slug}"
|
||||
|
||||
|
||||
def _psycopg_dsn(database_url: str) -> str:
|
||||
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
|
||||
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
|
||||
cur = conn.execute(sql)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def tiptap_blocks(node: Any) -> list[str]:
|
||||
"""متن بلوکبهبلوک از سند TipTap (یا هر JSON تو در تو) بیرون میکشد."""
|
||||
if not isinstance(node, dict):
|
||||
return []
|
||||
ntype = node.get("type")
|
||||
if ntype in ("paragraph", "heading", "codeBlock"):
|
||||
text = _inline_text(node).strip()
|
||||
return [text] if text else []
|
||||
blocks: list[str] = []
|
||||
for child in node.get("content") or []:
|
||||
blocks.extend(tiptap_blocks(child))
|
||||
return blocks
|
||||
|
||||
|
||||
def _inline_text(node: dict) -> str:
|
||||
parts: list[str] = []
|
||||
for child in node.get("content") or []:
|
||||
ctype = child.get("type")
|
||||
if ctype == "text":
|
||||
parts.append(child.get("text", ""))
|
||||
elif ctype == "hardBreak":
|
||||
parts.append(" ")
|
||||
elif ctype == "image":
|
||||
alt = (child.get("attrs") or {}).get("alt") or ""
|
||||
if alt:
|
||||
parts.append(f"[تصویر: {alt}]")
|
||||
else:
|
||||
parts.append(_inline_text(child))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def body_to_text(body: Any) -> str:
|
||||
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
|
||||
if not body:
|
||||
return ""
|
||||
if isinstance(body, list):
|
||||
# فرمت قدیمی: آرایه رشتهای پاراگرافها
|
||||
return "\n".join(str(p).strip() for p in body if str(p).strip())
|
||||
blocks = tiptap_blocks(body)
|
||||
return "\n".join(b for b in blocks if b)
|
||||
|
||||
|
||||
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return {"text": text, "type": dtype, "title": title, "url": url}
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return normalize(str(value or "")).strip()
|
||||
|
||||
|
||||
def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
"""همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
|
||||
dsn = _psycopg_dsn(database_url)
|
||||
docs: list[dict] = []
|
||||
|
||||
# --- برند و اطلاعات تماس (رکورد یگانه) ---
|
||||
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||
if brand_rows:
|
||||
b = brand_rows[0]
|
||||
contact = "\n".join(
|
||||
filter(None, [
|
||||
f"نام مجموعه: {_clean(b['name'])}",
|
||||
f"تلفن تماس: {_clean(b['phone'])}",
|
||||
f"ایمیل: {_clean(b['email'])}",
|
||||
f"آدرس: {_clean(b['address'])}",
|
||||
])
|
||||
)
|
||||
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- دفاتر ---
|
||||
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
|
||||
d = _doc(
|
||||
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
|
||||
"office", f"دفتر {_clean(row['city'])}", "/contact",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- خدمات ---
|
||||
for row in _fetch_rows(dsn, "SELECT title, desc FROM services ORDER BY position"):
|
||||
d = _doc(
|
||||
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
"service", _clean(row["title"]), "/services",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- پرسشهای متداول ---
|
||||
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
|
||||
d = _doc(
|
||||
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
|
||||
"faq", _clean(row["question"]), "/faq",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- پروژهها ---
|
||||
for row in _fetch_rows(
|
||||
dsn,
|
||||
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
|
||||
):
|
||||
meta = "\n".join(filter(None, [
|
||||
f"مکان: {_clean(row['location'])}" if row["location"] else "",
|
||||
f"سال: {_clean(row['year'])}" if row["year"] else "",
|
||||
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
|
||||
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
|
||||
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
|
||||
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
|
||||
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
|
||||
]))
|
||||
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
|
||||
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مقالهها (فقط منتشرشده) ---
|
||||
for row in _fetch_rows(
|
||||
dsn,
|
||||
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
|
||||
):
|
||||
text = "\n".join(filter(None, [
|
||||
f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
|
||||
_clean(row["excerpt"]),
|
||||
body_to_text(row["body"]),
|
||||
]))
|
||||
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مدلهای سرمایهگذاری ---
|
||||
for row in _fetch_rows(dsn, "SELECT title, desc, points FROM investment_models ORDER BY position"):
|
||||
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
||||
d = _doc(
|
||||
"\n".join(filter(None, [
|
||||
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
f"نکات: {points}" if points else "",
|
||||
])),
|
||||
"investment", _clean(row["title"]), "/investment",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مراحل همکاری ---
|
||||
for row in _fetch_rows(dsn, "SELECT step, title, desc FROM process_steps ORDER BY position"):
|
||||
d = _doc(
|
||||
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"process", _clean(row["title"]), "/investment",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
|
||||
for row in _fetch_rows(dsn, "SELECT title, desc FROM why_us ORDER BY position"):
|
||||
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
|
||||
d = _doc(
|
||||
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
|
||||
"team", _clean(row["name"]), "/about",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
|
||||
d = _doc(
|
||||
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
|
||||
"testimonial", _clean(row["name"]), "/",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT year, title, desc FROM timeline ORDER BY position"):
|
||||
d = _doc(
|
||||
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"timeline", _clean(row["title"]), "/about",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
|
||||
if story_rows and story_rows[0]:
|
||||
s = story_rows[0]
|
||||
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
|
||||
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
|
||||
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
log.info("Fetched %d documents from site database", len(docs))
|
||||
return docs
|
||||
|
||||
|
||||
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
|
||||
"""متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
|
||||
|
||||
هر قطعه یک doc_id پایدار میگیرد («type:ترتیب در همان url:شماره قطعه») تا
|
||||
همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
|
||||
"""
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
from .vectorstore import chunk_content_hash
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=overlap,
|
||||
separators=["\n", "۔", ".", "،", " ", ""],
|
||||
)
|
||||
|
||||
# شماره ردیف هر سند در میان سندهای همtype و هم-url (برای FAQs که url یکسان دارند)
|
||||
occurrence: dict[str, int] = {}
|
||||
|
||||
out: list[dict] = []
|
||||
for doc in docs:
|
||||
key = f"{doc['type']}|{doc['url']}"
|
||||
occ = occurrence.get(key, 0)
|
||||
occurrence[key] = occ + 1
|
||||
|
||||
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
|
||||
for i, piece in enumerate(pieces, 1):
|
||||
chunk = {
|
||||
"doc_id": f"{doc['type']}:{occ}:{i}",
|
||||
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
|
||||
"type": doc["type"],
|
||||
"title": doc["title"],
|
||||
"url": doc["url"],
|
||||
}
|
||||
chunk["content_hash"] = chunk_content_hash(chunk)
|
||||
out.append(chunk)
|
||||
|
||||
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
|
||||
return out
|
||||
|
||||
|
||||
def content_fingerprint(docs: list[dict]) -> str:
|
||||
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام میشود."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# --- زمینه همیشهتازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
|
||||
_site_context_cache: tuple[float, str] | None = None
|
||||
|
||||
|
||||
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
||||
"""نام برند، تماس و فهرست خدمات — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
|
||||
global _site_context_cache
|
||||
import time as _time
|
||||
|
||||
now = _time.time()
|
||||
if _site_context_cache and now - _site_context_cache[0] < ttl:
|
||||
return _site_context_cache[1]
|
||||
try:
|
||||
dsn = _psycopg_dsn(database_url)
|
||||
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
|
||||
lines: list[str] = []
|
||||
if brand:
|
||||
b = brand[0]
|
||||
lines += [
|
||||
f"نام مجموعه: {_clean(b['name'])}",
|
||||
f"تلفن تماس: {_clean(b['phone'])}",
|
||||
f"ایمیل: {_clean(b['email'])}",
|
||||
f"آدرس: {_clean(b['address'])}",
|
||||
]
|
||||
if services:
|
||||
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
|
||||
ctx = "\n".join(lines)
|
||||
_site_context_cache = (now, ctx)
|
||||
return ctx
|
||||
except Exception as e:
|
||||
log.warning("Site context fetch failed: %s", e)
|
||||
return _site_context_cache[1] if _site_context_cache else ""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""مدل امبدینگ — با لود تنبل تا اولین استفاده (برای تستهای سبک، torch لود نمیشود)."""
|
||||
|
||||
import os
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_embeddings():
|
||||
from langchain_huggingface import HuggingFaceEmbeddings
|
||||
|
||||
os.makedirs(settings.hf_cache_dir, exist_ok=True)
|
||||
return HuggingFaceEmbeddings(
|
||||
model_name=settings.embedding_model,
|
||||
model_kwargs={"device": "cpu"},
|
||||
encode_kwargs={"normalize_embeddings": False},
|
||||
cache_folder=settings.hf_cache_dir,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""ایندکس ترکیبی روی pgvector + BM25 در حافظه.
|
||||
|
||||
بردارها در pgvector (دیتابیس اختصاصی چتبات) بهصورت افزایشی همگام میشوند —
|
||||
فقط سطرهای جدید یا تغییر یافته امبد میشوند. BM25 چون سبک است در هر شروع
|
||||
از روی همان سطرها ساخته میشود و نیازی به کش دیسکی ندارد.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
from . import content as content_mod
|
||||
from .config import settings
|
||||
from .persian import tokenize
|
||||
from . import vectorstore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SiteIndex:
|
||||
"""حالت در حافظهٔ بازیابی — سندها (برای BM25) و وضعیت آماده بودن."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.docs: list[dict] = []
|
||||
self.bm25: BM25Okapi | None = None
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self.bm25 is not None and bool(self.docs)
|
||||
|
||||
|
||||
INDEX = SiteIndex()
|
||||
|
||||
|
||||
def sync_index(force_rebuild: bool = False) -> dict:
|
||||
"""همگامسازی کامل: محتوا → قطعه → pgvector (افزایشی) → ساخت BM25.
|
||||
|
||||
خروجی: آمار همگامسازی. خطای اتصال به دیتابیس بالا فرستاده میشود.
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
vectorstore.ensure_schema()
|
||||
|
||||
docs = content_mod.fetch_site_documents(settings.database_url)
|
||||
chunks = content_mod.chunk_documents(docs)
|
||||
stats = vectorstore.sync_chunks(chunks, force=force_rebuild)
|
||||
|
||||
INDEX.docs = vectorstore.fetch_all_chunks()
|
||||
corpus_tokens = [tokenize(d["text"]) for d in INDEX.docs]
|
||||
INDEX.bm25 = BM25Okapi(corpus_tokens) if corpus_tokens else None
|
||||
|
||||
log.info("Index ready in %.1fs (%d vectors, %d docs, %s)",
|
||||
time.time() - start, len(INDEX.docs), len(chunks), stats)
|
||||
return stats
|
||||
@@ -0,0 +1,79 @@
|
||||
"""نرمالسازی متن فارسی و توکنسازی برای جستوجوی کلیدواژه (BM25).
|
||||
|
||||
بدون وابستگی سنگین — فقط استانداردسازی نویسهها، حذف اعراب و نیمفاصلههای
|
||||
مشکلی و شکستن کلمههای مرکب روی نیمفاصله برای بهبود بازیابی.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# نویسههای عربی که در متن فارسی اشتباهاً استفاده میشوند
|
||||
_CHAR_MAP = {
|
||||
"\u064a": "\u06cc", # ي → ی
|
||||
"\u0643": "\u06a9", # ك → ک
|
||||
"\u0623": "\u0627", # أ → ا
|
||||
"\u0625": "\u0627", # إ → ا
|
||||
"\u0671": "\u0627", # ٱ → ا
|
||||
"\u0629": "\u0647", # ة → ه
|
||||
"\u0624": "\u0648", # ؤ → و
|
||||
"\u0626": "\u06cc", # ئ → ی
|
||||
"\u0640": "", # ـ (کشیده) حذف
|
||||
}
|
||||
|
||||
# اعراب و علامتهای اضافی
|
||||
_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655\u0640]")
|
||||
|
||||
# ارقام فارسی و عربی → ASCII
|
||||
_DIGITS = str.maketrans({
|
||||
"\u06f0": "0", "\u06f1": "1", "\u06f2": "2", "\u06f3": "3", "\u06f4": "4",
|
||||
"\u06f5": "5", "\u06f6": "6", "\u06f7": "7", "\u06f8": "8", "\u06f9": "9",
|
||||
"\u0660": "0", "\u0661": "1", "\u0662": "2", "\u0663": "3", "\u0664": "4",
|
||||
"\u0665": "5", "\u0666": "6", "\u0667": "7", "\u0668": "8", "\u0669": "9",
|
||||
})
|
||||
|
||||
# واژههای دستوری پرتکرار — در جستوجو بیمعنا هستند
|
||||
STOPWORDS = frozenset("""
|
||||
و در به از که با این آن است هست بود بودن شد شدن شود کرد کردن کرده کند کنی
|
||||
برای را های ها هایی هم نیز اما یا اگر تا بر روی زیر بین پس قبل بعد همه هر چه
|
||||
چیزی کس کسی کجا چرا چگونه چطور چند یکی دو سه ما شما آنها ایشان من تو او
|
||||
کدام کی چی چهقدر چقدر
|
||||
می نخواهد خواهد باشد باشند هستیم هستند بودم بودیم دارد دارند داشته داد دهد
|
||||
بله خیر آیا فقط نیز خیلی بسیار کمی دیگر دیگری همان همین وقتی زمانی وقتیکه
|
||||
اساساً لطفاً میشه میکنه هستن هستم
|
||||
""".split())
|
||||
|
||||
# علائم نگارشی فارسی/لاتین — قبل از توکنسازی حذف میشوند (نیمفاصله جزو اینها نیست)
|
||||
_PUNCT_RE = re.compile(r"[،؛؟!,;:\?\"'()\[\]{}«»….*#@&%+=/\\|~^$\u066d\u066c\u066b]+")
|
||||
|
||||
# توکن: حروف/ارقام فارسی و لاتین + نیمفاصله در میان کلمه
|
||||
_TOKEN_RE = re.compile(r"[\w\u0600-\u06ff\u200c]+")
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""نویسههای غیراستاندارد عربی/فارسی را به شکل یکدست فارسی میبرد."""
|
||||
if not text:
|
||||
return ""
|
||||
out = []
|
||||
for ch in text:
|
||||
out.append(_CHAR_MAP.get(ch, ch))
|
||||
text = "".join(out)
|
||||
text = _DIACRITICS.sub("", text)
|
||||
text = text.translate(_DIGITS)
|
||||
# فاصلههای تکراری را یکی کن — نیمفاصله عمداً حفظ میشود (میشود)
|
||||
text = re.sub(r"[ \t\r\n]+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
"""توکنهای نرمالشده برای BM25 — کلمههای مرکب روی نیمفاصله هم جدا ایندکس میشوند."""
|
||||
text = _PUNCT_RE.sub(" ", normalize(text).lower())
|
||||
tokens = []
|
||||
for raw in _TOKEN_RE.findall(text):
|
||||
if raw in STOPWORDS or len(raw) < 2:
|
||||
continue
|
||||
tokens.append(raw)
|
||||
# «میشود» → می + شود (فراخوانی بیشتر برای بازیابی)
|
||||
if "\u200c" in raw:
|
||||
for part in raw.split("\u200c"):
|
||||
if len(part) >= 2 and part not in STOPWORDS:
|
||||
tokens.append(part)
|
||||
return tokens
|
||||
@@ -0,0 +1,191 @@
|
||||
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
|
||||
|
||||
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفهجویی در تأخیر)
|
||||
۲) بازیابی ترکیبی با چند نوعواژه (اختیاری)
|
||||
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import json as _json
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
from . import content as content_mod
|
||||
from .config import settings
|
||||
from .retrieval import retrieve_final
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_MESSAGE = (
|
||||
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
|
||||
"برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
|
||||
)
|
||||
LLM_UNAVAILABLE = (
|
||||
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
|
||||
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چتبات تنظیم کنید."
|
||||
)
|
||||
|
||||
_llm = None
|
||||
|
||||
|
||||
def get_llm() -> ChatOpenAI | None:
|
||||
"""LLM تولید — اگر پیکربندی نشده باشد None برمیگرداند."""
|
||||
global _llm
|
||||
if _llm is None and settings.llm_base_url:
|
||||
_llm = ChatOpenAI(
|
||||
model=settings.llm_model,
|
||||
base_url=settings.llm_base_url,
|
||||
api_key=settings.llm_api_key or "not-needed",
|
||||
temperature=0,
|
||||
streaming=True,
|
||||
)
|
||||
return _llm
|
||||
|
||||
|
||||
class GraphState(TypedDict):
|
||||
messages: Annotated[List, add_messages]
|
||||
documents: List[str]
|
||||
contextualized_query: Optional[str]
|
||||
|
||||
|
||||
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
|
||||
|
||||
def contextualize_query(question: str, history: list) -> str:
|
||||
"""ضمیرها و اشارههای پرسش را با تاریخچه گفتگو مشخص میکند («اون دومی» → موضوع واقعی)."""
|
||||
model = get_llm()
|
||||
if model is None or not history:
|
||||
return question
|
||||
try:
|
||||
history_text = "\n".join(
|
||||
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
|
||||
for m in history[-6:]
|
||||
)
|
||||
prompt = (
|
||||
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
|
||||
"- ضمیرها و اشارهها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
|
||||
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
|
||||
"- فقط متن پرسش بازنویسیشده را برگردان؛ هیچ توضیحی ننویس.\n\n"
|
||||
f"تاریخچه گفتگو:\n{history_text}\n\n"
|
||||
f"پرسش کاربر: {question}\n\n"
|
||||
"پرسش مستقل:"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
|
||||
if not rewritten or len(rewritten) > len(question) * 4:
|
||||
return question
|
||||
if rewritten != question:
|
||||
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
|
||||
return rewritten
|
||||
except Exception as e:
|
||||
log.warning("Contextualization failed: %s", e)
|
||||
return question
|
||||
|
||||
|
||||
def expand_query(query: str) -> list[str]:
|
||||
"""گسترش پرسش به چند نوعواژه (اختیاری — پیشفرض خاموش برای پیکره کوچک)."""
|
||||
model = get_llm()
|
||||
if model is None:
|
||||
return [query]
|
||||
try:
|
||||
prompt = (
|
||||
"برای جستوجو در محتوای سایت یک گروه ساختمانی، ۳ نوعواژه جایگزین برای این پرسش بساز: "
|
||||
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
|
||||
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
|
||||
f"پرسش: {query}"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||
text = resp.content if isinstance(resp.content, str) else ""
|
||||
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
|
||||
if match:
|
||||
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
|
||||
if variants:
|
||||
log.info("Query variants: %s", [query] + variants)
|
||||
return [query] + variants
|
||||
except Exception as e:
|
||||
log.warning("Query expansion failed: %s", e)
|
||||
return [query]
|
||||
|
||||
|
||||
# --- گام ۲: بازیابی ---
|
||||
|
||||
def retrieve_node(state: GraphState) -> dict:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return {"documents": []}
|
||||
question = messages[-1].content
|
||||
history = messages[:-1]
|
||||
|
||||
query = contextualize_query(question, history) if settings.contextualize_enabled else question
|
||||
variants = expand_query(query) if settings.query_expansion else [query]
|
||||
|
||||
docs = retrieve_final(query, variants)
|
||||
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
|
||||
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
|
||||
return {
|
||||
"documents": doc_texts,
|
||||
"contextualized_query": query if query != question else None,
|
||||
}
|
||||
|
||||
|
||||
# --- گام ۳: تولید پاسخ ---
|
||||
|
||||
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
|
||||
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
|
||||
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
|
||||
reminder = (
|
||||
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
|
||||
if contextualized_query else ""
|
||||
)
|
||||
return (
|
||||
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ میدهی.\n"
|
||||
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
|
||||
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
|
||||
f"--- اسناد بازیابیشده مرتبط با پرسش ---\n{docs_block}\n"
|
||||
f"{reminder}"
|
||||
"--- قوانین پاسخ ---\n"
|
||||
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ بههیچوجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
|
||||
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفهای و محترمانه داشته باشد.\n"
|
||||
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
|
||||
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
|
||||
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در دادهها آمده است."
|
||||
)
|
||||
|
||||
|
||||
def generate_node(state: GraphState) -> dict:
|
||||
messages = state.get("messages", [])
|
||||
question = messages[-1].content if messages else ""
|
||||
|
||||
model = get_llm()
|
||||
if model is None:
|
||||
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
|
||||
|
||||
documents = state.get("documents") or []
|
||||
system = build_system_prompt(documents, state.get("contextualized_query"))
|
||||
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
|
||||
|
||||
history = messages[:-1]
|
||||
reply = model.invoke(
|
||||
[SystemMessage(content=system), *history, HumanMessage(content=question)],
|
||||
config={"tags": ["generate"]},
|
||||
)
|
||||
return {"messages": [reply]}
|
||||
|
||||
|
||||
# --- گراف ---
|
||||
|
||||
memory = MemorySaver()
|
||||
workflow = StateGraph(GraphState)
|
||||
workflow.add_node("retrieve", retrieve_node)
|
||||
workflow.add_node("generate", generate_node)
|
||||
workflow.set_entry_point("retrieve")
|
||||
workflow.add_edge("retrieve", "generate")
|
||||
workflow.add_edge("generate", END)
|
||||
graph = workflow.compile(checkpointer=memory)
|
||||
@@ -0,0 +1,20 @@
|
||||
# سرویس چتبات — بازیابی ترکیبی روی محتوای سایت
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic-settings>=2.3
|
||||
|
||||
# گراف گفتگو و LLM
|
||||
langgraph>=0.2.60
|
||||
langchain>=0.3
|
||||
langchain-openai>=0.2
|
||||
langchain-text-splitters>=0.3
|
||||
|
||||
# بازیابی — بردارها در pgvector میروند؛ BM25 و خوشهبندی در حافظه
|
||||
sentence-transformers>=3.0
|
||||
langchain-huggingface>=0.1
|
||||
rank-bm25>=0.2.2
|
||||
scikit-learn>=1.4
|
||||
numpy>=1.26
|
||||
|
||||
# دیتابیس (فقط خواندن)
|
||||
psycopg[binary]>=3.1
|
||||
@@ -0,0 +1,50 @@
|
||||
"""مدل ریرنکر BGE — با لود تنبل و امتیازدهی دستهای."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _CrossEncoder:
|
||||
"""غلاف سبک روی AutoModelForSequenceClassification — API شبیه rank_bm25."""
|
||||
def __init__(self) -> None:
|
||||
import torch
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
|
||||
os.makedirs(settings.hf_cache_dir, exist_ok=True)
|
||||
self._torch = torch
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(settings.reranker_model, cache_dir=settings.hf_cache_dir)
|
||||
self.model = AutoModelForSequenceClassification.from_pretrained(
|
||||
settings.reranker_model, cache_dir=settings.hf_cache_dir
|
||||
)
|
||||
device = settings.reranker_device if settings.reranker_device == "cuda" and torch.cuda.is_available() else "cpu"
|
||||
self.model.to(device)
|
||||
self.model.eval()
|
||||
self.device = device
|
||||
log.info("Reranker loaded on %s", device)
|
||||
|
||||
def score(self, pairs: list[list[str]], batch_size: int = 16) -> list[float]:
|
||||
torch = self._torch
|
||||
out: list[float] = []
|
||||
with torch.no_grad():
|
||||
for i in range(0, len(pairs), batch_size):
|
||||
inputs = self.tokenizer(
|
||||
pairs[i:i + batch_size],
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=512,
|
||||
return_tensors="pt",
|
||||
).to(self.device)
|
||||
logits = self.model(**inputs).logits.squeeze(-1)
|
||||
out.extend(logits.cpu().tolist())
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_reranker() -> _CrossEncoder:
|
||||
return _CrossEncoder()
|
||||
@@ -0,0 +1,165 @@
|
||||
"""بازیابی ترکیبی — پورت خط لوله v13 با سه تغییر برای این سایت:
|
||||
|
||||
۱) پایگاه sparse بهجای SPLADE (انگلیسیمحور و بیفایده روی فارسی) از BM25
|
||||
با نرمالسازی فارسی استفاده میکند.
|
||||
۲) جستوجوی معنایی بهجای FAISS با pgvector و فاصله کسینوسی در SQL انجام
|
||||
میشود (همگامسازی افزایشی، فیلترپذیر، بدون فایل ایندکس جدا).
|
||||
۳) مسیریابی alpha با LLM حذف شده و وزن ثابت تنظیمپذیر است — برای پیکرهٔ
|
||||
کوچک سایت، هزینهٔ یک فراخوانی LLM اضافی بهازای هر پیام نمیارزد.
|
||||
بقیهٔ منطق (نرمالسازی نمره، فیوژن وزندار، برش خوشهبندی، ریرنک) عین v13 است.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from . import vectorstore
|
||||
from .config import settings
|
||||
from .indexing import INDEX
|
||||
from .persian import tokenize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def dense_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی معنایی pgvector — خروجی: (سند، شباهت کسینوسی)."""
|
||||
return vectorstore.dense_search(query, top_k)
|
||||
|
||||
|
||||
def keyword_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی کلیدواژه BM25 روی متن نرمالشده فارسی."""
|
||||
if INDEX.bm25 is None:
|
||||
return []
|
||||
try:
|
||||
tokens = tokenize(query)
|
||||
if not tokens:
|
||||
return []
|
||||
scores = INDEX.bm25.get_scores(tokens)
|
||||
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
|
||||
return [(INDEX.docs[i], float(scores[i])) for i in ranked if scores[i] > 0]
|
||||
except Exception as e:
|
||||
log.error("BM25 retrieval failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _minmax(scores: list[float]) -> list[float]:
|
||||
if not scores:
|
||||
return []
|
||||
lo, hi = min(scores), max(scores)
|
||||
if hi == lo:
|
||||
return [0.5] * len(scores)
|
||||
return [(s - lo) / (hi - lo) for s in scores]
|
||||
|
||||
|
||||
def hybrid_retrieve(query: str, alpha: float | None = None, top_k: int | None = None) -> list[tuple[dict, float]]:
|
||||
"""ترکیب وزندار نمره معنایی و کلیدواژه؛ خروجی مرتبشده بر اساس نمره ترکیبی."""
|
||||
alpha = settings.alpha_dense if alpha is None else alpha
|
||||
top_k = top_k or settings.retrieve_k
|
||||
|
||||
dense = dense_retrieve(query, top_k)
|
||||
keyword = keyword_retrieve(query, top_k)
|
||||
log.info("Retrieval '%s': dense=%d, keyword=%d", query[:60], len(dense), len(keyword))
|
||||
if not dense and not keyword:
|
||||
return []
|
||||
|
||||
dense_norm = _minmax([s for _, s in dense])
|
||||
keyword_norm = _minmax([s for _, s in keyword])
|
||||
|
||||
pool: dict = {} # doc_id → {doc, dense_max, kw_max}
|
||||
for (doc, _), norm in zip(dense, dense_norm):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
|
||||
entry["dense_max"] = max(entry["dense_max"], norm)
|
||||
for (doc, _), norm in zip(keyword, keyword_norm):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
|
||||
entry["kw_max"] = max(entry["kw_max"], norm)
|
||||
|
||||
combined = [
|
||||
(entry["doc"], alpha * entry["dense_max"] + (1 - alpha) * entry["kw_max"])
|
||||
for entry in pool.values()
|
||||
]
|
||||
combined.sort(key=lambda x: x[1], reverse=True)
|
||||
return combined
|
||||
|
||||
|
||||
def cluster_cutoff(scored: list[tuple[dict, float]], silhouette_threshold: float = 0.55) -> list[tuple[dict, float]]:
|
||||
"""برش خوشهای v13 — مرز طبیعی کیفیت بهجای top-k دلخواه.
|
||||
|
||||
روی نمرههای ۱بعدی K-Means با K=2 میزند؛ اگر جدایی خوشهها معنادار
|
||||
باشد (silhouette ≥ 0.55) فقط خوشهٔ بالا را نگه میدارد.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
n = len(scored)
|
||||
if n < 4:
|
||||
return scored
|
||||
try:
|
||||
from sklearn.cluster import KMeans
|
||||
from sklearn.metrics import silhouette_score
|
||||
|
||||
scores = np.array([s for _, s in scored], dtype=float).reshape(-1, 1)
|
||||
kmeans = KMeans(n_clusters=2, random_state=42, n_init=10)
|
||||
labels = kmeans.fit_predict(scores)
|
||||
sil = silhouette_score(scores, labels)
|
||||
if sil < silhouette_threshold:
|
||||
log.info("Clustering: silhouette=%.3f < %.2f — keeping all %d docs", sil, silhouette_threshold, n)
|
||||
return scored
|
||||
centers = kmeans.cluster_centers_.flatten()
|
||||
good_label = int(np.argmax(centers))
|
||||
cutoff = int(np.sum(labels == good_label))
|
||||
cutoff = max(cutoff, 1)
|
||||
log.info("Clustering: silhouette=%.3f — cutting at %d/%d docs", sil, cutoff, n)
|
||||
return scored[:cutoff]
|
||||
except Exception as e:
|
||||
log.warning("Clustering failed (%s) — keeping all docs", e)
|
||||
return scored
|
||||
|
||||
|
||||
def rerank(query: str, scored: list[tuple[dict, float]], top_k: int) -> list[tuple[dict, float]]:
|
||||
"""ریرنک کراسانکودر BGE — مرتبسازی نهایی بر اساس شباهت واقعی پرسش/سند."""
|
||||
if not scored:
|
||||
return []
|
||||
if not settings.reranker_enabled:
|
||||
return scored[:top_k]
|
||||
|
||||
try:
|
||||
from .reranker import get_reranker
|
||||
|
||||
start = time.time()
|
||||
pairs = [[query, doc["text"]] for doc, _ in scored]
|
||||
scores = get_reranker().score(pairs)
|
||||
rescored = sorted(
|
||||
((doc, float(s)) for (doc, _), s in zip(scored, scores)),
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)
|
||||
log.info("Reranked %d docs in %.2fs — top scores: %s",
|
||||
len(scored), time.time() - start,
|
||||
[f"{s:.2f}" for _, s in rescored[:3]])
|
||||
return rescored[:top_k]
|
||||
except Exception as e:
|
||||
log.error("Reranking failed (%s) — falling back to fusion order", e)
|
||||
return scored[:top_k]
|
||||
|
||||
|
||||
def retrieve_final(query: str, query_variants: list[str] | None = None) -> list[dict]:
|
||||
"""خط کامل بازیابی برای یک پرسش: هر نوع → فیوژن → برش خوشه → ریرنک → K نهایی."""
|
||||
variants = query_variants or [query]
|
||||
|
||||
pool: dict = {}
|
||||
for variant in variants:
|
||||
for doc, score in hybrid_retrieve(variant):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "score": 0.0})
|
||||
entry["score"] = max(entry["score"], score)
|
||||
|
||||
combined = sorted(((e["doc"], e["score"]) for e in pool.values()),
|
||||
key=lambda x: x[1], reverse=True)
|
||||
if not combined:
|
||||
return []
|
||||
|
||||
filtered = cluster_cutoff(combined)
|
||||
# ریرنک روی نتیجههای برشخورده (سقف retrieve_k تا تأخیر کنترل شود)
|
||||
rerank_candidates = filtered[: settings.retrieve_k]
|
||||
final = rerank(query, rerank_candidates, settings.best_of_k)
|
||||
|
||||
log.info("Final retrieval: %d docs (from %d candidates, %d variants)",
|
||||
len(final), len(combined), len(variants))
|
||||
return [doc for doc, _ in final]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""تستهای خالص (بدون مدل/دیتابیس) برای ماژولهای چتبات — اجرا: python test_pure.py"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
|
||||
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
|
||||
from chatbot.persian import normalize, tokenize
|
||||
|
||||
docs = [
|
||||
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
|
||||
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
|
||||
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
|
||||
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
|
||||
]
|
||||
chunks = chunk_documents(docs)
|
||||
ids = [c["doc_id"] for c in chunks]
|
||||
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
|
||||
|
||||
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
|
||||
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
|
||||
|
||||
art = [c for c in chunks if c["type"] == "article"]
|
||||
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
|
||||
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
|
||||
|
||||
c = chunks[0]
|
||||
assert c["content_hash"] == chunk_content_hash(c)
|
||||
c2 = dict(c, title="عنوان عوض شد")
|
||||
# hash فیلد کپیشده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
|
||||
assert chunk_content_hash(c2) != c["content_hash"]
|
||||
|
||||
chunks2 = chunk_documents(docs)
|
||||
assert [x["doc_id"] for x in chunks2] == ids
|
||||
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
|
||||
|
||||
settings_dsn = chatbot_dsn()
|
||||
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
|
||||
assert "+psycopg" not in settings_dsn
|
||||
|
||||
# نرمالسازی و توکنسازی فارسی
|
||||
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
|
||||
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
|
||||
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
|
||||
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
|
||||
|
||||
# استخراج متن TipTap و فرمت قدیمی
|
||||
tiptap_doc = {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
|
||||
{"type": "paragraph", "content": [
|
||||
{"type": "text", "text": "این برج در تهران "},
|
||||
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
|
||||
{"type": "hardBreak"},
|
||||
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
|
||||
]},
|
||||
{"type": "bulletList", "content": [
|
||||
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
|
||||
]},
|
||||
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
|
||||
],
|
||||
}
|
||||
blocks = tiptap_blocks(tiptap_doc)
|
||||
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
|
||||
assert body_to_text(["الف", "ب"]) == "الف\nب"
|
||||
assert body_to_text(None) == ""
|
||||
|
||||
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
|
||||
@@ -0,0 +1,211 @@
|
||||
"""ذخیرهساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
|
||||
|
||||
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
|
||||
نگهداری میشوند تا دیتابیس سایت فقطخواندنی بماند. همگامسازی افزایشی است:
|
||||
فقط سطرهای جدید/تغییریافته امبد میشوند (کلید: doc_id پایدار + hash محتوا).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _strip_driver(dsn: str) -> str:
|
||||
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def chatbot_dsn() -> str:
|
||||
"""DSN دیتابیس اختصاصی چتبات — از DATABASE_URL با تعویض نام دیتابیس ساخته میشود."""
|
||||
parts = urlsplit(_strip_driver(settings.database_url))
|
||||
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
|
||||
|
||||
|
||||
def _admin_dsn() -> str:
|
||||
"""DSN دیتابیس postgres برای ساخت دیتابیس چتبات (فقط یکبار)."""
|
||||
parts = urlsplit(_strip_driver(settings.database_url))
|
||||
return urlunsplit(parts._replace(path="/postgres"))
|
||||
|
||||
|
||||
def ensure_schema() -> None:
|
||||
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا میشود."""
|
||||
import psycopg
|
||||
from psycopg import errors as pg_errors
|
||||
|
||||
try:
|
||||
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
try:
|
||||
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
|
||||
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
|
||||
except pg_errors.DuplicateDatabase:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS chatbot_chunks (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
embedding vector({settings.embedding_dim}) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
|
||||
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||
"""همگامسازی افزایشی قطعات با جدول pgvector.
|
||||
|
||||
- سطرهایی که دیگر وجود ندارند حذف میشوند
|
||||
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert میشوند
|
||||
- force=True همه سطرها را از نو امبد میکند
|
||||
خروجی: آمار عملیات برای لاگ.
|
||||
"""
|
||||
import psycopg
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
|
||||
model = settings.embedding_model
|
||||
wanted = {c["doc_id"]: c for c in chunks}
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
existing: dict[str, str] = {}
|
||||
if not force:
|
||||
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
|
||||
if row["model"] == model:
|
||||
existing[row["doc_id"]] = row["content_hash"]
|
||||
else:
|
||||
conn.execute("DELETE FROM chatbot_chunks")
|
||||
|
||||
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
|
||||
if to_delete:
|
||||
conn.execute(
|
||||
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
|
||||
[to_delete],
|
||||
)
|
||||
|
||||
to_upsert = [
|
||||
c for c in chunks
|
||||
if existing.get(c["doc_id"]) != c["content_hash"]
|
||||
]
|
||||
inserted = updated = 0
|
||||
|
||||
if to_upsert:
|
||||
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
|
||||
for chunk, vec in zip(to_upsert, vectors):
|
||||
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s::vector)
|
||||
ON CONFLICT (doc_id) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
model = EXCLUDED.model,
|
||||
content = EXCLUDED.content,
|
||||
embedding = EXCLUDED.embedding
|
||||
""",
|
||||
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
|
||||
)
|
||||
if chunk["doc_id"] in existing:
|
||||
updated += 1
|
||||
else:
|
||||
inserted += 1
|
||||
|
||||
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
|
||||
"unchanged": len(wanted) - len(to_upsert)}
|
||||
log.info("Vector sync: %s", stats)
|
||||
return stats
|
||||
|
||||
|
||||
def fetch_all_chunks() -> list[dict]:
|
||||
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"doc_id": r["doc_id"],
|
||||
"content_hash": r["content_hash"],
|
||||
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def vector_count() -> int:
|
||||
"""تعداد بردارهای ذخیرهشده — برای healthz (خطا → ۰)."""
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
|
||||
|
||||
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
|
||||
"""
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row as _dict_row
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
|
||||
vec = get_embeddings().embed_query(query)
|
||||
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
|
||||
FROM chatbot_chunks
|
||||
ORDER BY embedding <=> %s::vector
|
||||
LIMIT %s
|
||||
""",
|
||||
(literal, literal, top_k),
|
||||
).fetchall()
|
||||
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
|
||||
except Exception as e:
|
||||
log.warning("Dense search failed (%s) — falling back to keyword only", e)
|
||||
return []
|
||||
|
||||
|
||||
def _row_to_doc(row: Any) -> dict:
|
||||
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
|
||||
return {
|
||||
"doc_id": row["doc_id"],
|
||||
"text": content.get("text", ""),
|
||||
"type": content.get("type", ""),
|
||||
"title": content.get("title", ""),
|
||||
"url": content.get("url", ""),
|
||||
}
|
||||
|
||||
|
||||
def chunk_content_hash(chunk: dict) -> str:
|
||||
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید بهروز شود."""
|
||||
payload = json.dumps(
|
||||
{k: chunk[k] for k in ("text", "type", "title", "url")},
|
||||
ensure_ascii=False, sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
@@ -0,0 +1,189 @@
|
||||
# لایه مانیتورینگ — Prometheus، Grafana، Loki، Alertmanager و صادرکنندهها
|
||||
#
|
||||
# توسعه: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||
# تولید: سه فایل با هم: base + monitoring + prod (بخش مانیتورینگ README)
|
||||
#
|
||||
# داشبورد: grafana.localhost (توسعه) — رمز از GRAFANA_ADMIN_PASSWORD در .env
|
||||
# هشدارها: Prometheus → Alertmanager → تلگرام (ALERT_TELEGRAM_* در .env)
|
||||
# منابع: مجموعاً حدود ۱ تا ۱.۵ گیگابایت RAM — با حذف هر سرویس، بقیه سرپا میمانند.
|
||||
|
||||
x-default-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.53.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=15d
|
||||
- --storage.tsdb.retention.size=2GB
|
||||
volumes:
|
||||
- ./monitoring/prometheus:/etc/prometheus:ro
|
||||
- prometheus_data:/prometheus
|
||||
networks: [monitoring, edge]
|
||||
environment:
|
||||
# پروب عمومی — در تولید SITE_URL در .env تنظیم میشود (مثل https://aramesh.example)
|
||||
SITE_URL: ${SITE_URL:-http://frontend/}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --config.file=/etc/alertmanager/alertmanager.rendered.yml
|
||||
- --storage.path=/alertmanager
|
||||
# فایل رندرشدهٔ alertmanager.rendered.yml را scripts/stack.sh از قالب
|
||||
# alertmanager.yml با مقادیر .env (ALERT_BOT_TOKEN/ALERT_CHAT_ID) میسازد —
|
||||
# Alertmanager خودش متغیر محیطی را در کانفیگ جایگزین نمیکند
|
||||
volumes:
|
||||
- ./monitoring/alertmanager:/etc/alertmanager:ro
|
||||
- alertmanager_data:/alertmanager
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
# منابع هر کانتینر — روی Linux سرور کامل کار میکند؛ روی Docker Desktop ویندوز
|
||||
# ممکن است محدود باشد (حذفش بقیه استک را نمیشکند)
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
privileged: true
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 192M
|
||||
|
||||
# منابع میزبان + سنجهٔ تازگی پشتیبان (backups/*.prom نوشتهٔ scripts/backup.sh)
|
||||
node-exporter:
|
||||
image: quay.io/prometheus/node-exporter:v1.8.2
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --path.rootfs=/host
|
||||
- --collector.textfile.directory=/textfile
|
||||
volumes:
|
||||
- /:/host:ro
|
||||
- ./backups:/textfile:ro
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
postgres-exporter:
|
||||
image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATA_SOURCE_NAME: postgresql://aramesh:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}@db:5432/arameshplaza?sslmode=disable
|
||||
networks: [internal, monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:v0.25.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
# edge برای رسیدن به سرویسهایی که پروب میشوند (frontend/minio/umami/chatbot/backend)
|
||||
networks: [monitoring, edge]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
# لاگ همهٔ کانتینرها — جستوجو در Grafana؛ خطاهای بکاند از همینجا دیده میشوند
|
||||
loki:
|
||||
image: grafana/loki:3.1.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: -config.file=/etc/loki/loki-config.yml
|
||||
volumes:
|
||||
- ./monitoring/loki:/etc/loki:ro
|
||||
- loki_data:/loki
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:3.1.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: -config.file=/etc/promtail/promtail-config.yml
|
||||
volumes:
|
||||
- ./monitoring/promtail:/etc/promtail:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- promtail_positions:/positions
|
||||
networks: [monitoring]
|
||||
depends_on: [loki]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.1.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD را در .env تنظیم کنید}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
networks: [monitoring, edge]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || wget -q --spider http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.grafana.rule=Host(`grafana.localhost`)
|
||||
- traefik.http.routers.grafana.entrypoints=web
|
||||
- traefik.http.services.grafana.loadbalancer.server.port=3000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
|
||||
networks:
|
||||
monitoring:
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
alertmanager_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
promtail_positions:
|
||||
+98
-16
@@ -1,15 +1,28 @@
|
||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۴۳.
|
||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۳.
|
||||
# پیشنیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ بهعلاوه رکورد DNS دامنه به این سرور.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
# bash scripts/stack.sh deploy-prod
|
||||
# (معادل: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build)
|
||||
#
|
||||
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
|
||||
# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
|
||||
#
|
||||
# ---- پیشنمایش خصوصی و انتشار (scripts/preview-mode.sh) ----
|
||||
# در حالت پیشنمایش (PREVIEW_MODE=on) کل سایت پشت Basic Auth است و noindex میگیرد:
|
||||
# تیم با یک نام کاربری/رمز مشترک وارد میشود، محتوا را ویرایش میکند و هیچکس دیگری —
|
||||
# از جمله گوگل — به سایت دسترسی ندارد. برای انتشار عمومی:
|
||||
# bash scripts/preview-mode.sh off → deploy مجدد → ثبت sitemap در Search Console.
|
||||
#
|
||||
# DOMAIN دامنه اصلی است و DOMAIN_ALIAS دامنه دوم (هر دو مسیر و گواهی میگیرند).
|
||||
# در زمان انتشار، برای جلوگیری از محتوای تکراری، دامنه دوم را به اصلی ریدایرکت کنید:
|
||||
# ALIAS_REDIRECT=on (در .env) — ریدایرکت ۳۰۱ دائمی به دامنه اصلی.
|
||||
|
||||
services:
|
||||
traefik:
|
||||
command: !override
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
@@ -18,6 +31,9 @@ services:
|
||||
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.le.acme.httpchallenge=true
|
||||
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
||||
- --ping=true
|
||||
- --accesslog=true
|
||||
- --metrics.prometheus=true
|
||||
ports: !override
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
@@ -25,26 +41,40 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt_data:/letsencrypt
|
||||
|
||||
backend:
|
||||
environment:
|
||||
# سایت هممبدأ است — CORS لازم نیست
|
||||
CORS_ORIGINS: '[]'
|
||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=websecure
|
||||
- traefik.http.routers.api.tls.certresolver=le
|
||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||
- traefik.http.routers.seo.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
|
||||
# MinIO کنسول (رمز root) و API مستقیمش فقط از localhost سرور در دسترس باشند —
|
||||
# از راه دور با تونل SSH: ssh -L 9001:127.0.0.1:9001 server
|
||||
minio:
|
||||
ports: !override
|
||||
- "127.0.0.1:9001:9001"
|
||||
- "127.0.0.1:19000:9000"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
|
||||
- traefik.http.routers.media.entrypoints=websecure
|
||||
- traefik.http.routers.media.tls.certresolver=le
|
||||
- traefik.http.routers.media.priority=100
|
||||
- traefik.http.routers.media.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}media-rewrite,sec-headers
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||
|
||||
backend:
|
||||
environment:
|
||||
# سایت هممبدأ است — CORS لازم نیست
|
||||
CORS_ORIGINS: '[]'
|
||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
||||
# پیشنمایش خصوصی — هدر noindex + robots مسدودکننده (لایه دوم؛ لایه اول گیت Traefik)
|
||||
PREVIEW_MODE: ${PREVIEW_MODE:-off}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=websecure
|
||||
- traefik.http.routers.api.tls.certresolver=le
|
||||
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||
- traefik.http.routers.seo.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||
|
||||
frontend:
|
||||
labels:
|
||||
@@ -52,6 +82,58 @@ services:
|
||||
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||
- traefik.http.routers.web.entrypoints=websecure
|
||||
- traefik.http.routers.web.tls.certresolver=le
|
||||
- traefik.http.routers.web.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||
# تعریف یکبارهٔ میانافزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده میشود
|
||||
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
|
||||
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
|
||||
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
|
||||
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
|
||||
# ---- گیت پیشنمایش خصوصی ----
|
||||
# ورود با نام کاربری/رمز مشترک (htpasswd) — تا وقتی PREVIEW_MIDDLEWARES تنظیم است فعال میشود
|
||||
- traefik.http.middlewares.preview-auth.basicauth.users=${PREVIEW_AUTH_USERS:-disabled:nope}
|
||||
- traefik.http.middlewares.preview-auth.basicauth.realm=Aramesh private preview
|
||||
# لایه دوم جلوگیری از ایندکس — حتی اگر گیت حذف شد، گوگل چیزی ذخیره نکند
|
||||
- traefik.http.middlewares.preview-noindex.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow, noarchive
|
||||
# ---- ریدایرکت دامنه دوم به اصلی (زمان انتشار: ALIAS_REDIRECT=on) ----
|
||||
- traefik.http.middlewares.to-primary.redirectregex.regex=^https?://[^/]+(.*)
|
||||
- traefik.http.middlewares.to-primary.redirectregex.replacement=https://${DOMAIN}$$1
|
||||
- traefik.http.middlewares.to-primary.redirectregex.permanent=true
|
||||
- traefik.http.routers.alias-redirect.rule=Host(`${DOMAIN_ALIAS:-invalid.invalid}`)
|
||||
- traefik.http.routers.alias-redirect.entrypoints=websecure
|
||||
- traefik.http.routers.alias-redirect.tls.certresolver=le
|
||||
- traefik.http.routers.alias-redirect.priority=1000
|
||||
- traefik.http.routers.alias-redirect.middlewares=${ALIAS_REDIRECT_MIDDLEWARES:-sec-headers}
|
||||
- traefik.http.routers.alias-redirect.service=web
|
||||
|
||||
chatbot:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.chat.rule=Host(`${DOMAIN}`) && PathPrefix(`/chat`)
|
||||
- traefik.http.routers.chat.entrypoints=websecure
|
||||
- traefik.http.routers.chat.tls.certresolver=le
|
||||
- traefik.http.routers.chat.priority=100
|
||||
- traefik.http.routers.chat.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||
|
||||
umami:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.analytics.rule=Host(`analytics.${DOMAIN}`)
|
||||
- traefik.http.routers.analytics.entrypoints=websecure
|
||||
- traefik.http.routers.analytics.tls.certresolver=le
|
||||
- traefik.http.routers.analytics.middlewares=sec-headers
|
||||
- traefik.http.services.analytics.loadbalancer.server.port=3000
|
||||
|
||||
# داشبورد Grafana (docker-compose.monitoring.yml) در تولید با TLS نیاز به روتر
|
||||
# Host(`grafana.DOMAIN`) دارد — چون آن سرویس فقط با فایل monitoring بالا میآید،
|
||||
# برچسبهایش را در override سرور بگذارید (README، بخش مانیتورینگ):
|
||||
# docker-compose.override.yml روی سرور:
|
||||
# services:
|
||||
# grafana:
|
||||
# labels:
|
||||
# - traefik.http.routers.grafana.rule=Host(`grafana.YOURDOMAIN`)
|
||||
# - traefik.http.routers.grafana.entrypoints=websecure
|
||||
# - traefik.http.routers.grafana.tls.certresolver=le
|
||||
# - traefik.http.services.grafana.loadbalancer.server.port=3000
|
||||
|
||||
volumes:
|
||||
letsencrypt_data:
|
||||
|
||||
+234
-21
@@ -1,69 +1,121 @@
|
||||
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
||||
# cp .env.example .env
|
||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
|
||||
# cp .env.example .env (یا: bash scripts/gen-env.sh)
|
||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml هم سوار شود (README):
|
||||
# bash scripts/stack.sh deploy-prod
|
||||
#
|
||||
# لایههای اختیاری:
|
||||
# docker-compose.monitoring.yml — Prometheus/Grafana/Loki (پروفایل زیرساخت)
|
||||
# پروفایل ops — پشتیبان ساعتی دیتابیس
|
||||
# پروفایل tools — ابزار mc برای پشتیبانگیری دستی
|
||||
|
||||
x-default-logging: &default-logging
|
||||
# بدون این، لاگ json-file بیمرز رشد میکند و دیسک سرور را پر میکند
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند) و /media (MinIO)
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند)، /media (MinIO)،
|
||||
# /chat (چتبات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
|
||||
traefik:
|
||||
image: traefik:v3
|
||||
# v3.3+ قدیمیترین کلاینت داکر را دارد که با Engineهای جدید (API 1.5x) مشکل دارد —
|
||||
# با v3.6 پین شده است
|
||||
image: traefik:v3.6
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||
- --entrypoints.web.address=:80
|
||||
# healthcheck داخلی + متریکها برای Prometheus + لاگ دسترسی
|
||||
- --ping=true
|
||||
- --accesslog=true
|
||||
- --metrics.prometheus=true
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks: [edge]
|
||||
healthcheck:
|
||||
test: ["CMD", "traefik", "healthcheck", "--ping"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
# pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چتبات
|
||||
image: pgvector/pgvector:pg17
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
POSTGRES_USER: aramesh
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
POSTGRES_DB: arameshplaza
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
shm_size: 256m
|
||||
networks: [internal]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
networks: [edge, internal]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
|
||||
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو میشوند)
|
||||
# در تولید، docker-compose.prod.yml اینها را فقط روی 127.0.0.1 میبندد —
|
||||
# کنسول رمز root دارد و نباید به اینترنت باز باشد
|
||||
- "9001:9001"
|
||||
- "19000:9000"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.media.rule=PathPrefix(`/media`)
|
||||
- traefik.http.routers.media.entrypoints=web
|
||||
- traefik.http.routers.media.priority=100
|
||||
- traefik.http.routers.media.middlewares=media-rewrite
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.routers.media.middlewares=media-rewrite
|
||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed.
|
||||
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبتاند) روی یک حجم تازه
|
||||
# وارد باکت میشوند تا هر نسخه تازهکلون/بیلد، همان عکسها را داشته باشد؛
|
||||
# روی حجم موجود، mc cp فایلهای تکراری را رد میکند و دادهتازه را نمیخورد.
|
||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed
|
||||
# فقط وقتی باکت خالی است — تا هر deploy، کپی تکراری تصویرها را تکرار نکند.
|
||||
minio-setup:
|
||||
image: minio/mc:latest
|
||||
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
@@ -74,19 +126,51 @@ services:
|
||||
volumes:
|
||||
- ./seed-media:/seed-media:ro
|
||||
restart: "no"
|
||||
networks: [internal]
|
||||
logging: *default-logging
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||
mc mb --ignore-existing local/$$MINIO_BUCKET &&
|
||||
mc anonymous set download local/$$MINIO_BUCKET &&
|
||||
echo 'minio-setup: bucket ready' &&
|
||||
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ &&
|
||||
echo 'minio-setup: seed media mirrored'
|
||||
COUNT=$$(mc ls --recursive local/$$MINIO_BUCKET | wc -l) &&
|
||||
if [ \"$$COUNT\" -eq 0 ]; then
|
||||
echo 'minio-setup: bucket ready — mirroring seed media';
|
||||
mc mirror --overwrite /seed-media local/$$MINIO_BUCKET/;
|
||||
else
|
||||
echo \"minio-setup: bucket ready — $$COUNT object(s) already present, seed mirror skipped\";
|
||||
fi
|
||||
"
|
||||
|
||||
# یکبار اجرا میشود: دیتابیسهای جانبی روی همان نمونهٔ Postgres
|
||||
# (umami برای آنالیتیکس، aramesh_chatbot برای بردارهای چتبات)
|
||||
db-init:
|
||||
image: postgres:17-alpine
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGUSER: aramesh
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
networks: [internal]
|
||||
logging: *default-logging
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
set -e;
|
||||
for DBN in umami aramesh_chatbot; do
|
||||
if ! psql -d arameshplaza -tAc \"SELECT 1 FROM pg_database WHERE datname='$$DBN'\" | grep -q 1; then
|
||||
psql -d arameshplaza -c \"CREATE DATABASE $$DBN\";
|
||||
fi;
|
||||
echo \"db-init: $$DBN ready\";
|
||||
done
|
||||
"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||
@@ -104,11 +188,14 @@ services:
|
||||
FRONTEND_DIST: /srv/frontend
|
||||
volumes:
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||
interval: 15s
|
||||
@@ -117,38 +204,129 @@ services:
|
||||
start_period: 20s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.api.rule=PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=web
|
||||
- traefik.http.routers.api.priority=100
|
||||
# لایه دوم محافظت از فرمها — سیلاب درخواست را پیش از رسیدن به بکاند میبُرد؛
|
||||
# محدودسازی دقیق هر فرم در خود بکاند است (rate_limit.py)
|
||||
- traefik.http.routers.api.middlewares=api-flood
|
||||
- traefik.http.middlewares.api-flood.ratelimit.average=50
|
||||
- traefik.http.middlewares.api-flood.ratelimit.period=1s
|
||||
- traefik.http.middlewares.api-flood.ratelimit.burst=150
|
||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
||||
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
|
||||
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
|
||||
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
|
||||
- traefik.http.routers.seo.entrypoints=web
|
||||
- traefik.http.routers.seo.priority=50
|
||||
- traefik.http.routers.seo.service=api
|
||||
# /metrics فقط برای Prometheus — روتری تعریف نمیشود، از بیرون در دسترس نیست
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
|
||||
# فقط سرو فایلهای استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- backend
|
||||
- minio
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks: [edge]
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.web.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.web.entrypoints=web
|
||||
- traefik.http.routers.web.priority=1
|
||||
- traefik.http.services.web.loadbalancer.server.port=80
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
# دستیار هوشمند — RAG روی محتوای سایت با pgvector
|
||||
# اولین اجرا مدلها را دانلود میکند (~۵۰۰MB روی حجم chatbot_hf) — کند است، طبیعی است
|
||||
chatbot:
|
||||
build: ./chatbot
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-openai/gpt-oss-120b}
|
||||
CHAT_REINDEX_TOKEN: ${CHAT_REINDEX_TOKEN:-}
|
||||
volumes:
|
||||
- chatbot_hf:/data
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.chat.rule=PathPrefix(`/chat`)
|
||||
- traefik.http.routers.chat.entrypoints=web
|
||||
- traefik.http.routers.chat.priority=100
|
||||
- traefik.http.routers.chat.middlewares=api-flood
|
||||
- traefik.http.services.chat.loadbalancer.server.port=8000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
|
||||
# آنالیتیکس محصول (Umami) — بازدید، ارجاع، رخدادهای سفارشی (فرم/تلفن/چتبات)
|
||||
# داشبورد: analytics.localhost (توسعه) یا analytics.DOMAIN (تولید)
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-v2.17.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql://aramesh:${POSTGRES_PASSWORD}@db:5432/umami
|
||||
# اسکریپت ردیاب به ارجاعدهندهٔ واقعی نیاز دارد — پشت Traefik
|
||||
CLIENT_IP_HEADER: x-forwarded-for
|
||||
DISABLE_TELEMETRY: 1
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
# 127.0.0.1 نه localhost — «localhost» به ::1 حل میشود و umami فقط IPv4 گوش میدهد
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/heartbeat || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.analytics.rule=Host(`analytics.localhost`)
|
||||
- traefik.http.routers.analytics.entrypoints=web
|
||||
- traefik.http.services.analytics.loadbalancer.server.port=3000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
# ابزار پشتیبانگیری — فقط با پروفایل tools اجرا میشود (scripts/backup.sh این کار را میکند)
|
||||
mc:
|
||||
image: minio/mc:latest
|
||||
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||
profiles: ["tools"]
|
||||
depends_on:
|
||||
minio:
|
||||
@@ -156,7 +334,42 @@ services:
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
networks: [internal]
|
||||
|
||||
# پشتیبان ساعتی خودکار دیتابیس — بدون نیاز به cron میزبان؛ فقط با پروفایل ops
|
||||
# docker compose --profile ops up -d db-backup
|
||||
# RPO را از ۲۴ ساعت (cron شبانه) به ۱ ساعت میرساند؛ scripts/backup.sh برای
|
||||
# پشتیبان کامل (دیتابیس + تصاویر MinIO) سر جایش است.
|
||||
db-backup:
|
||||
image: prodrigestivill/postgres-backup-local:17
|
||||
profiles: ["ops"]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_DB: arameshplaza
|
||||
POSTGRES_USER: aramesh
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
SCHEDULE: "@hourly"
|
||||
BACKUP_NUM_KEEP: "48"
|
||||
BACKUP_DIR: /backups
|
||||
volumes:
|
||||
- backups:/backups
|
||||
networks: [internal]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
networks:
|
||||
edge: # سرویسهایی که Traefik به آنها مسیریابی میکند
|
||||
internal: # دیتابیس و ذخیرهسازی — فرانتاند راهی به این شبکه ندارد
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
minio_data:
|
||||
chatbot_hf:
|
||||
backups:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# فقط Caddyfile و dist/ به کانتکست بیلد میروند — node_modules و سورس عظیماند و لازم نیستند
|
||||
*
|
||||
!Caddyfile
|
||||
!dist
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,47 +0,0 @@
|
||||
// تست موقت رندر استاتیک و ابزارها — پس از اجرا حذف میشود
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer'
|
||||
import { ARTICLE_EXTENSIONS } from './src/components/admin/ArticleEditor'
|
||||
import { formatReadingTime, readingTimeMinutes } from './src/utils/readingTime'
|
||||
import { buildLinkRel, normalizeHref, parseLinkRel } from './src/utils/link'
|
||||
import { docHeadings, docImages, docToText } from './src/utils/tiptapDoc'
|
||||
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'متن نمونه مقاله برای تست رندر' }] },
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { src: '/media/a.jpg', alt: 'تصویر تست', title: 'Photo 1', caption: 'کپشن تصویر تست' },
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'لینک خارجی',
|
||||
marks: [{ type: 'link', attrs: { href: 'https://example.com', target: '_blank', rel: 'noopener noreferrer nofollow' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'تیتر دوم' }] },
|
||||
],
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: doc as never })
|
||||
|
||||
console.log('--- rendered html ---')
|
||||
console.log(html)
|
||||
console.log('--- checks ---')
|
||||
console.log('has figure:', html.includes('<figure'))
|
||||
console.log('has figcaption:', html.includes('<figcaption') && html.includes('کپشن تصویر تست'))
|
||||
console.log('has img src:', html.includes('src="/media/a.jpg"'))
|
||||
console.log('has alt:', html.includes('alt="تصویر تست"'))
|
||||
console.log('has title:', html.includes('title="Photo 1"'))
|
||||
console.log('link rel/target:', html.includes('rel="noopener noreferrer nofollow"'), html.includes('target="_blank"'))
|
||||
console.log('--- text utils ---')
|
||||
console.log('text:', docToText(doc))
|
||||
console.log('h2:', docHeadings(doc, 2), '| images:', docImages(doc).length)
|
||||
console.log('reading time:', formatReadingTime(doc), '| minutes:', readingTimeMinutes(doc))
|
||||
console.log('--- link helpers ---')
|
||||
console.log('normalize:', normalizeHref('example.com'), '|', normalizeHref('/contact'), '|', normalizeHref('javascript:alert(1)'))
|
||||
console.log('rel:', buildLinkRel({ newTab: true, nofollow: true }), '|', buildLinkRel({ newTab: false, nofollow: false }), '|', parseLinkRel('noopener noreferrer').newTab)
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: { entry: '.tmp-render-test.ts', formats: ['cjs'], fileName: () => 'tmp-render-test.cjs' },
|
||||
outDir: '.tmp-render-out',
|
||||
minify: false,
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
+11
-2
@@ -1,12 +1,21 @@
|
||||
{
|
||||
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
|
||||
admin off
|
||||
}
|
||||
|
||||
:80 {
|
||||
root * /srv
|
||||
encode gzip
|
||||
encode zstd gzip
|
||||
|
||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# کش بلندمدت برای فایلهای هشدار بیلد
|
||||
# فایلهای هشدار بیلد: کش بلندمدت بیرحم
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||
|
||||
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
|
||||
@notassets not path /assets/*
|
||||
header @notassets Cache-Control "no-cache"
|
||||
}
|
||||
|
||||
+11
-1
@@ -4,7 +4,17 @@
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
# نسخهٔ alpine کاربر caddy ندارد — اینجا ساخته میشود؛ /config و /data مسیرهای
|
||||
# storage سیدی هستند و باید برای کاربر نوشتنی باشند
|
||||
RUN addgroup -S caddy \
|
||||
&& adduser -S -G caddy -s /sbin/nologin caddy \
|
||||
&& mkdir -p /config /data \
|
||||
&& chown -R caddy:caddy /config /data
|
||||
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY dist /srv
|
||||
COPY --chown=caddy:caddy dist /srv
|
||||
|
||||
# بدون root — فقط فایلهای استاتیک سرو میشوند و TLS را Traefik تمام میکند
|
||||
USER caddy
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// زبان و جهت سند پیش از اولین رندر، از خود مسیر — /en/... انگلیسی و LTR است
|
||||
// تا جهت صفحه از نخستین فریم درست باشد (نسخه انگلیسی سمت سرور هم همین را تزریق میکند)
|
||||
if (location.pathname === '/en' || location.pathname.startsWith('/en/')) {
|
||||
document.documentElement.setAttribute('lang', 'en');
|
||||
document.documentElement.setAttribute('dir', 'ltr');
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
// تحلیل رفتار (Umami) — در زمان بیلد تزریق میشود؛ VITE_UMAMI_SRC خالی یعنی خاموش
|
||||
if (import.meta.env.VITE_UMAMI_SRC) {
|
||||
const s = document.createElement('script');
|
||||
s.defer = true;
|
||||
s.src = import.meta.env.VITE_UMAMI_SRC;
|
||||
s.setAttribute('data-website-id', import.meta.env.VITE_UMAMI_WEBSITE_ID ?? '');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+45
-26
@@ -5,6 +5,7 @@ import { Preloader } from './components/Preloader';
|
||||
import { Header } from './components/Header';
|
||||
import { Footer } from './components/Footer';
|
||||
import { FloatingContact } from './components/FloatingContact';
|
||||
import { ChatWidget } from './components/ChatWidget';
|
||||
import { Home } from './pages/Home';
|
||||
import { About } from './pages/About';
|
||||
import { ServicesPage } from './pages/ServicesPage';
|
||||
@@ -15,12 +16,35 @@ import { Blog } from './pages/Blog';
|
||||
import { ArticleDetail } from './pages/ArticleDetail';
|
||||
import { FaqPage } from './pages/FaqPage';
|
||||
import { ContactPage } from './pages/ContactPage';
|
||||
import { SiteProvider, useSiteData } from './api';
|
||||
import { SiteProvider, useSiteData, useSite } from './api';
|
||||
import { applySeo, seoKeyForPath } from './seo';
|
||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||
import { AdminBar } from './admin/AdminBar';
|
||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||
import { LangProvider, useLang } from './i18n/lang';
|
||||
|
||||
// مسیرهای عمومی یک بار تعریف میشوند و دو بار سرو میشوند: ریشه فارسی و /en انگلیسی
|
||||
const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
|
||||
{ path: '/', element: <Home /> },
|
||||
{ path: '/about', element: <About /> },
|
||||
{ path: '/services', element: <ServicesPage /> },
|
||||
{ path: '/projects', element: <Projects /> },
|
||||
{ path: '/projects/:slug', element: <ProjectDetail /> },
|
||||
{ path: '/investment', element: <Investment /> },
|
||||
{ path: '/blog', element: <Blog /> },
|
||||
{ path: '/blog/:slug', element: <ArticleDetail /> },
|
||||
{ path: '/faq', element: <FaqPage /> },
|
||||
{ path: '/contact', element: <ContactPage /> },
|
||||
];
|
||||
|
||||
// فرزند مستقیم <Routes> باید <Route> باشد — آرایههای Route بهجای کامپوننت واسط
|
||||
const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
||||
<Route key={path} path={path} element={element} />
|
||||
));
|
||||
const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
|
||||
<Route key={`en-${path}`} path={path === '/' ? '/en' : `/en${path}`} element={element} />
|
||||
));
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -45,17 +69,9 @@ function AnimatedRoutes() {
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:slug" element={<ProjectDetail />} />
|
||||
<Route path="/investment" element={<Investment />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/blog/:slug" element={<ArticleDetail />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
{FA_ROUTES}
|
||||
<Route path="*" element={<Home />} />
|
||||
{EN_ROUTES}
|
||||
</Routes>
|
||||
</motion.main>
|
||||
</AnimatePresence>);
|
||||
@@ -65,12 +81,13 @@ function AnimatedRoutes() {
|
||||
function AppChrome() {
|
||||
const { pathname } = useLocation();
|
||||
const { isAuthenticated } = useAdmin();
|
||||
const { data } = useSiteData();
|
||||
const data = useSite();
|
||||
const { lang } = useLang();
|
||||
const isAdminPage = pathname.startsWith('/admin');
|
||||
|
||||
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
||||
useEffect(() => {
|
||||
if (isAdminPage || !data) return;
|
||||
if (isAdminPage) return;
|
||||
const key = seoKeyForPath(pathname);
|
||||
if (!key) return;
|
||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||
@@ -80,8 +97,9 @@ function AppChrome() {
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
lang,
|
||||
});
|
||||
}, [pathname, data, isAdminPage]);
|
||||
}, [pathname, data, isAdminPage, lang]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,6 +107,7 @@ function AppChrome() {
|
||||
<AnimatedRoutes />
|
||||
{!isAdminPage && <Footer />}
|
||||
{!isAdminPage && <FloatingContact />}
|
||||
{!isAdminPage && <ChatWidget />}
|
||||
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||
</>
|
||||
);
|
||||
@@ -96,46 +115,46 @@ function AppChrome() {
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
const { t, dir, lang } = useLang();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
|
||||
<h1 className="text-2xl font-bold text-ivory">{t('errorTitle')}</h1>
|
||||
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
|
||||
محتوای سایت از سرور بارگذاری میشود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
|
||||
{t('errorText')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
تلاش دوباره
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : data ? (
|
||||
<BrowserRouter>
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<AppChrome />
|
||||
</BrowserRouter>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('dir', 'rtl');
|
||||
document.documentElement.setAttribute('lang', 'fa');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteProvider>
|
||||
<AdminProvider>
|
||||
<AppShell />
|
||||
<BrowserRouter>
|
||||
<LangProvider>
|
||||
<AppShell />
|
||||
</LangProvider>
|
||||
</BrowserRouter>
|
||||
</AdminProvider>
|
||||
</SiteProvider>
|
||||
);
|
||||
|
||||
@@ -126,7 +126,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -161,6 +161,19 @@ export function AdminBar() {
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند ویرایشها روی کدام ستون زبان ذخیره شوند"
|
||||
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'border-copper bg-copper/10 text-copper'
|
||||
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
|
||||
</button>
|
||||
|
||||
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
@@ -175,6 +188,28 @@ export function AdminBar() {
|
||||
رمز
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی پیامها', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
|
||||
})}
|
||||
title="دانلود پیامهای فرم تماس بهصورت CSV"
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
پیامها (CSV)
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('subscribers').catch((e) => {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی خبرنامه', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
|
||||
})}
|
||||
title="دانلود مشترکان خبرنامه بهصورت CSV"
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
خبرنامه (CSV)
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
|
||||
@@ -4,8 +4,11 @@ import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||
import { ENTITIES, isTranslatable } from './entities'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||
export type EditLang = 'fa' | 'en'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
@@ -16,6 +19,9 @@ interface AdminContextValue {
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
@@ -54,6 +60,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||
)
|
||||
const [editLang, setEditLang] = useState<EditLang>(
|
||||
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
|
||||
)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const { reload } = useSiteData()
|
||||
|
||||
@@ -64,6 +73,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleEditLang = useCallback(() => {
|
||||
setEditLang((v) => {
|
||||
const next: EditLang = v === 'fa' ? 'en' : 'fa'
|
||||
sessionStorage.setItem('aramesh_edit_lang', next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await adminApi.login(username, password)
|
||||
setIsAuthenticated(true)
|
||||
@@ -131,8 +148,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
logout,
|
||||
saveStatus,
|
||||
runSave,
|
||||
editLang,
|
||||
toggleEditLang,
|
||||
}),
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
||||
)
|
||||
|
||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||
@@ -141,9 +160,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||
*
|
||||
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت میشوند؛ چون
|
||||
* برخی mutatorهای کامپوننتها نام فیلد را جداگانه مینویسند، مقدار قبلی فیلدهای
|
||||
* فارسی اسنپشات میشود و پس از اجرای mutator بازگردانده میشود تا متن انگلیسی
|
||||
* هرگز جای متن فارسی در درخت خام ننشیند.
|
||||
*/
|
||||
export function useFieldSaver() {
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
|
||||
return useCallback(
|
||||
@@ -154,9 +178,50 @@ export function useFieldSaver() {
|
||||
apply: (data: SiteData) => void,
|
||||
): Promise<boolean> =>
|
||||
runSave(async () => {
|
||||
const originalKeys = Object.keys(changes)
|
||||
if (editLang === 'en') {
|
||||
const mapped: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
mapped[isTranslatable(resource, key) ? `${key}_en` : key] = value
|
||||
}
|
||||
for (const key of Object.keys(changes)) delete changes[key]
|
||||
Object.assign(changes, mapped)
|
||||
}
|
||||
await adminApi.patch(resource, id, changes)
|
||||
updateSite(apply)
|
||||
updateSite((d) => {
|
||||
const config = ENTITIES[resource]
|
||||
let node: Record<string, unknown> | undefined
|
||||
if (config) {
|
||||
const target = d[config.dataKey as keyof SiteData] as unknown
|
||||
node =
|
||||
config.kind === 'singleton'
|
||||
? (target as Record<string, unknown> | undefined)
|
||||
: (target as { id: number }[] | undefined)?.find((x) => x.id === id)
|
||||
}
|
||||
const snapshot =
|
||||
editLang === 'en' && node
|
||||
? Object.fromEntries(originalKeys.filter((k) => k !== 'id').map((k) => [k, node![k]]))
|
||||
: null
|
||||
apply(d)
|
||||
if (snapshot && node) {
|
||||
Object.assign(node, snapshot)
|
||||
Object.assign(node, changes)
|
||||
}
|
||||
})
|
||||
}),
|
||||
[runSave, updateSite],
|
||||
[runSave, updateSite, editLang],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
|
||||
* پسوند _en میگیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره میشود.
|
||||
*/
|
||||
export function useEditField() {
|
||||
const { editLang } = useAdmin()
|
||||
return useCallback(
|
||||
(resource: string, field: string) =>
|
||||
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
|
||||
[editLang],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Controller, useController, useForm } from 'react-hook-form'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
@@ -20,8 +20,8 @@ import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||||
|
||||
/**
|
||||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||
@@ -54,6 +54,75 @@ function normalizeTags(value: unknown): string[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** یک ردیف پرسشوپاسخ مقاله */
|
||||
type ArticleFaq = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* پنل پرسشوپاسخهای پایان مقاله — مثل بخش متا دیسکریپشن زیر ویرایشگر.
|
||||
* در زبان فارسی ستون faqs و در زبان انگلیسی faqs_en را ویرایش میکند؛
|
||||
* نسخه انگلیسیِ خالی در سایت به فارسی برمیگردد. ذخیرهسازی خودکار است.
|
||||
*/
|
||||
function FaqPanel({
|
||||
faqs,
|
||||
onChange,
|
||||
}: {
|
||||
faqs: ArticleFaq[]
|
||||
onChange: (next: ArticleFaq[]) => void
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<ArticleFaq>) => {
|
||||
const next = faqs.map((f, i) => (i === index ? { ...f, ...patch } : f))
|
||||
onChange(next)
|
||||
}
|
||||
const remove = (index: number) => onChange(faqs.filter((_, i) => i !== index))
|
||||
const add = () => onChange([...faqs, { question: '', answer: '' }])
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid gap-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<div key={i} className="grid gap-3 border border-ivory/10 bg-ink/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-light text-copper">پرسش {i + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
aria-label={`حذف پرسش ${i + 1}`}
|
||||
className="flex h-7 w-7 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={faq.question}
|
||||
onChange={(e) => update(i, { question: e.target.value })}
|
||||
placeholder="پرسش مخاطب…"
|
||||
aria-label={`متن پرسش ${i + 1}`}
|
||||
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
dir="auto"
|
||||
value={faq.answer}
|
||||
onChange={(e) => update(i, { answer: e.target.value })}
|
||||
placeholder="پاسخ کوتاه و روشن…"
|
||||
aria-label={`پاسخ پرسش ${i + 1}`}
|
||||
className="w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="flex w-fit items-center gap-2 border border-dashed border-ivory/25 px-4 py-2 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
افزودن پرسشوپاسخ
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'ذخیره شد',
|
||||
saving: 'در حال ذخیره…',
|
||||
@@ -65,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave } = useAdmin()
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
@@ -78,8 +147,11 @@ export function ArticleWritePage() {
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
title_en: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
date_en: '',
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
category_en: '',
|
||||
tags: [],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
@@ -87,8 +159,11 @@ export function ArticleWritePage() {
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
read_en: '',
|
||||
excerpt: '',
|
||||
excerpt_en: '',
|
||||
body: EMPTY_DOC,
|
||||
body_en: EMPTY_DOC,
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
@@ -147,11 +222,16 @@ export function ArticleWritePage() {
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||
setDraft({
|
||||
...article,
|
||||
tags: normalizeTags(article.tags),
|
||||
...(article as unknown as Draft),
|
||||
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
||||
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
body: toArticleBodyDoc(article.body),
|
||||
body_en:
|
||||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||||
? (rawEnBody as ArticleBodyDoc)
|
||||
: EMPTY_DOC,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||
@@ -259,6 +339,8 @@ export function ArticleWritePage() {
|
||||
})
|
||||
|
||||
const metaLength = (draft.meta_description ?? '').trim().length
|
||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||
|
||||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||
@@ -280,6 +362,18 @@ export function ArticleWritePage() {
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند متن مقاله روی کدام زبان ذخیره شود"
|
||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'bg-copper text-char'
|
||||
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
|
||||
}`}
|
||||
>
|
||||
{editLang === 'fa' ? 'ویرایش: فارسی' : 'Edit: English'}
|
||||
</button>
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
@@ -314,7 +408,7 @@ export function ArticleWritePage() {
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
<Link
|
||||
to={`/blog/${draft.slug}`}
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
data-admin-link
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
@@ -345,7 +439,8 @@ export function ArticleWritePage() {
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند */}
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
|
||||
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره میشود */}
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
@@ -355,9 +450,9 @@ export function ArticleWritePage() {
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value)
|
||||
queueSave({ title: event.target.value })
|
||||
queueSave({ [suf('title')]: event.target.value })
|
||||
}}
|
||||
placeholder="عنوان مقاله…"
|
||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
/>
|
||||
@@ -390,14 +485,19 @@ export function ArticleWritePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن */}
|
||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
contentKey={loadedContentKey}
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||
onChange={(json) => {
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
|
||||
queueSave({ body: json, read: formatReadingTime(json) })
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||||
const minutes = formatReadingTime(json)
|
||||
if (editLang === 'en') {
|
||||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||||
} else {
|
||||
queueSave({ body: json, read: `${minutes} دقیقه` })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -427,6 +527,20 @@ export function ArticleWritePage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* پرسشوپاسخهای پایان مقاله — سئوی FAQPage */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<h2 className="text-sm font-bold text-copper">پرسشهای متداول مقاله</h2>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
در پایان مقاله با ظاهر آکاردئونی سایت نمایش داده میشود و با اسکیمای FAQPage
|
||||
شانس نمایش در نتایج غنی گوگل را بالا میبرد.
|
||||
{editLang === 'en' && ' — ویرایش نسخه انگلیسی (خالی = نمایش متن فارسی)'}
|
||||
</p>
|
||||
<FaqPanel
|
||||
faqs={(editLang === 'en' ? draft.faqs_en : draft.faqs) ?? []}
|
||||
onChange={(next) => queueSave({ [suf('faqs')]: next })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
@@ -457,8 +571,8 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField('excerpt')}
|
||||
placeholder="خلاصه کوتاه مقاله برای کارتهای وبلاگ…"
|
||||
{...textField(suf('excerpt'))}
|
||||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارتهای وبلاگ…'}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -521,9 +635,13 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
{...textField('read')}
|
||||
className="mt-2 w-full cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-sm font-light text-ivory/50"
|
||||
disabled={editLang !== 'en'}
|
||||
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
|
||||
className={`mt-2 w-full text-sm font-light ${
|
||||
editLang === 'en'
|
||||
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
|
||||
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { editableFields } from './entities'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
@@ -23,11 +24,16 @@ type EntityDrawerProps = {
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const flushRef = useRef<() => void>(() => {})
|
||||
|
||||
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر میشوند؛
|
||||
// متن فارسی همان فیلد بهعنوان جاینگهدار نشان داده میشود تا مترجم زمینه را ببیند
|
||||
const fields = editableFields(config, editLang)
|
||||
const faBase = (name: string) => name.replace(/_en$/, '')
|
||||
|
||||
const closeWithFlush = () => {
|
||||
flushRef.current()
|
||||
onClose()
|
||||
@@ -114,7 +120,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{config.fields.map((field) => {
|
||||
{fields.map((field) => {
|
||||
const isImage = field.type === 'image'
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey = isImage
|
||||
@@ -135,6 +141,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||
altFallback={imageAltFallback}
|
||||
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||
@@ -179,6 +186,7 @@ function FieldControl({
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
placeholder,
|
||||
imageTitleValue,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
@@ -190,6 +198,8 @@ function FieldControl({
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||
placeholder?: unknown
|
||||
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||
imageTitleValue?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
@@ -269,14 +279,22 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
|
||||
return (
|
||||
<WysiwygField
|
||||
field={field}
|
||||
value={value}
|
||||
onSave={onSave}
|
||||
registerFlush={registerFlush}
|
||||
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را میبیند' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return field.ui === 'tags' ? (
|
||||
<TagsField field={field} value={value} onSave={onSave} />
|
||||
) : (
|
||||
<ListField field={field} value={value} onSave={onSave} />
|
||||
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -287,6 +305,7 @@ function FieldControl({
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={4}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -303,6 +322,7 @@ function FieldControl({
|
||||
<input
|
||||
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -345,10 +365,12 @@ function ListField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
hint?: string
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
@@ -361,6 +383,7 @@ function ListField({
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<div className="mt-2 grid gap-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
@@ -408,11 +431,13 @@ function WysiwygField({
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
hint?: string
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
@@ -457,6 +482,7 @@ function WysiwygField({
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<p className="mt-1 text-[10px] font-light text-ivory/40">
|
||||
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
|
||||
</p>
|
||||
|
||||
@@ -83,15 +83,40 @@ export const adminApi = {
|
||||
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||
|
||||
upload: async (file: File): Promise<{ url: string; key: string }> => {
|
||||
upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (name && name.trim()) fd.append('name', name.trim())
|
||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
||||
},
|
||||
|
||||
/** درونریزی تصویر از نشانی خارجی — دانلود و ذخیره در Object Storage خود سایت */
|
||||
importImage: async (url: string): Promise<{ url: string; key: string }> =>
|
||||
authFetch('/api/admin/import-image', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
}),
|
||||
|
||||
// خروجی CSV — فایل تولیدی بکاند (با BOM برای Excel) مستقیم دانلود میشود
|
||||
downloadCsv: async (kind: 'messages' | 'subscribers'): Promise<void> => {
|
||||
const path = kind === 'messages' ? 'messages/export.csv' : 'subscribers/export.csv'
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_URL}/api/admin/${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new HttpError(res.status, 'دریافت خروجی CSV ناموفق بود')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const disposition = res.headers.get('Content-Disposition') ?? ''
|
||||
a.download = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `${kind}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type FieldConfig = {
|
||||
creatable?: boolean
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||
shared?: boolean
|
||||
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||
altFallbackSuffix?: string
|
||||
}
|
||||
@@ -33,13 +35,15 @@ const F = (
|
||||
label: string,
|
||||
type: FieldType = 'text',
|
||||
hint?: string,
|
||||
extra?: Partial<FieldConfig>,
|
||||
sharedOrExtra?: boolean | Partial<FieldConfig>,
|
||||
): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
...extra,
|
||||
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
|
||||
? sharedOrExtra
|
||||
: { shared: !!sharedOrExtra }),
|
||||
})
|
||||
|
||||
/** دستهبندیهای استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده میشوند */
|
||||
@@ -55,16 +59,16 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)],
|
||||
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||
},
|
||||
projects: {
|
||||
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
|
||||
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||
],
|
||||
@@ -76,31 +80,31 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
|
||||
F('author', 'نویسنده'),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
||||
F('author', 'نویسنده', 'text', undefined, true),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
|
||||
F('read', 'زمان مطالعه (دقیقه)'),
|
||||
F('slug', 'نشانی (slug)'),
|
||||
F('slug', 'نشانی (slug)', 'text', undefined, true),
|
||||
],
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
},
|
||||
},
|
||||
testimonials: {
|
||||
@@ -124,13 +128,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image', undefined, true)],
|
||||
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
|
||||
},
|
||||
stats: {
|
||||
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
|
||||
},
|
||||
why_us: {
|
||||
@@ -160,20 +164,20 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
gallery: {
|
||||
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||
titleKey: 'alt',
|
||||
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
|
||||
fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')],
|
||||
defaults: { src: '', alt: 'تصویر گالری' },
|
||||
},
|
||||
nav: {
|
||||
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
seo_meta: {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
fields: [
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true),
|
||||
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
|
||||
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
|
||||
],
|
||||
@@ -182,25 +186,25 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پسزمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پسزمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
|
||||
fields: [F('title', 'عنوان بخش'), F('paragraphs', 'پاراگرافهای معرفی', 'list'), F('quote', 'نقلقول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقلقول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه', 'text', undefined, true)],
|
||||
defaults: {},
|
||||
},
|
||||
before_after: {
|
||||
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
defaults: {},
|
||||
},
|
||||
contact_section: {
|
||||
@@ -231,6 +235,22 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
},
|
||||
}
|
||||
|
||||
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
|
||||
export function isTranslatable(resource: string, name: string): boolean {
|
||||
if (name.endsWith('_en')) return false
|
||||
const config = ENTITIES[resource]
|
||||
return !!config?.fields.some((f) => f.name === name && !f.shared)
|
||||
}
|
||||
|
||||
/** فیلدهای قابل ویرایش یک منبع در زبان دادهشده — انگلیسی: بدون فیلدهای مشترک و با پسوند _en */
|
||||
export function editableFields(config: EntityConfig, lang: 'fa' | 'en'): FieldConfig[] {
|
||||
if (lang === 'fa') return config.fields
|
||||
return config.fields
|
||||
.filter((f) => !f.shared)
|
||||
.map((f) => ({ ...f, name: `${f.name}_en` }))
|
||||
}
|
||||
|
||||
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
|
||||
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
|
||||
return data[config.dataKey as keyof SiteData] as never
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// لایهٔ نازک تحلیل رفتار (Umami) — اگر اسکریپت ردیاب بارگیری نشده باشد
|
||||
// (VITE_UMAMI_SRC خالی)، همهٔ فراخوانیها بیاثر و ارزاناند.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
|
||||
}
|
||||
}
|
||||
|
||||
/** رخداد سفارشی — نام رخداد در داشبورد Umami نمایش داده میشود. */
|
||||
export function track(event: string, data?: Record<string, unknown>) {
|
||||
try {
|
||||
window.umami?.track(event, data)
|
||||
} catch {
|
||||
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
|
||||
}
|
||||
}
|
||||
+91
-20
@@ -3,11 +3,14 @@ import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
import { localizeSite } from './i18n/localize'
|
||||
import { useLang } from './i18n/lang'
|
||||
|
||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||
export type BrandInfo = {
|
||||
id: number
|
||||
name: string
|
||||
name_en?: string
|
||||
logo: string
|
||||
hero: string
|
||||
contact_bg: string
|
||||
@@ -15,105 +18,157 @@ export type BrandInfo = {
|
||||
phone_href: string
|
||||
email: string
|
||||
address: string
|
||||
address_en?: string
|
||||
}
|
||||
|
||||
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
|
||||
export type FaqItem = { id: number; question: string; answer: string }
|
||||
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
|
||||
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; label: string }
|
||||
export type WhyUsItem = { id: number; title: string; desc: string }
|
||||
export type TestimonialItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; quote: string; quote_en?: string }
|
||||
export type FaqItem = { id: number; question: string; question_en?: string; answer: string; answer_en?: string }
|
||||
export type TeamMemberItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; bio: string; bio_en?: string }
|
||||
export type TimelineItem = { id: number; year: string; year_en?: string; title: string; title_en?: string; desc: string; desc_en?: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; suffix_en?: string; label: string; label_en?: string }
|
||||
export type WhyUsItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string }
|
||||
export type BeforeAfterInfo = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
before: string
|
||||
after: string
|
||||
before_label: string
|
||||
before_label_en?: string
|
||||
after_label: string
|
||||
after_label_en?: string
|
||||
}
|
||||
|
||||
export type NavItem = { id: number; label: string; href: string }
|
||||
export type NavItem = { id: number; label: string; label_en?: string; href: string }
|
||||
|
||||
export type HeroContent = {
|
||||
id: number
|
||||
kicker: string
|
||||
kicker_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
title_highlight: string
|
||||
title_highlight_en?: string
|
||||
subtitle: string
|
||||
subtitle_en?: string
|
||||
badge_value: string
|
||||
badge_value_en?: string
|
||||
badge_label: string
|
||||
badge_label_en?: string
|
||||
cta_primary_label: string
|
||||
cta_primary_label_en?: string
|
||||
cta_primary_href: string
|
||||
cta_secondary_label: string
|
||||
cta_secondary_label_en?: string
|
||||
cta_secondary_href: string
|
||||
scroll_label: string
|
||||
scroll_label_en?: string
|
||||
}
|
||||
|
||||
export type StoryContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
paragraphs: string[]
|
||||
paragraphs_en?: string[] | null
|
||||
quote: string
|
||||
quote_en?: string
|
||||
quote_author: string
|
||||
quote_author_en?: string
|
||||
stat1_value: string
|
||||
stat1_value_en?: string
|
||||
stat1_label: string
|
||||
stat1_label_en?: string
|
||||
stat2_value: string
|
||||
stat2_value_en?: string
|
||||
stat2_label: string
|
||||
stat2_label_en?: string
|
||||
link_label: string
|
||||
link_label_en?: string
|
||||
link_href: string
|
||||
bento: { src: string; alt: string }[]
|
||||
bento_en?: { src: string; alt: string }[] | null
|
||||
}
|
||||
|
||||
export type SectionHeadingContent = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
image: string
|
||||
cta_label: string
|
||||
cta_label_en?: string
|
||||
cta_href: string
|
||||
}
|
||||
|
||||
export type PageHeroContent = { id: number; key: string; title: string; lead: string; image: string }
|
||||
export type PageHeroContent = { id: number; key: string; title: string; title_en?: string; lead: string; lead_en?: string; image: string }
|
||||
|
||||
export type InvestmentModelItem = { id: number; title: string; desc: string; points: string[] }
|
||||
export type ProcessStepItem = { id: number; step: string; title: string; desc: string }
|
||||
export type OfficeItem = { id: number; city: string; address: string; hours: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string }
|
||||
export type InvestmentModelItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string; points: string[]; points_en?: string[] | null }
|
||||
export type ProcessStepItem = { id: number; step: string; step_en?: string; title: string; title_en?: string; desc: string; desc_en?: string }
|
||||
export type OfficeItem = { id: number; city: string; city_en?: string; address: string; address_en?: string; hours: string; hours_en?: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string; alt_en?: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string; label_en?: string }
|
||||
|
||||
export type ContactSectionContent = {
|
||||
id: number
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
address_label: string
|
||||
address_label_en?: string
|
||||
phone_label: string
|
||||
phone_label_en?: string
|
||||
email_label: string
|
||||
email_label_en?: string
|
||||
name_label: string
|
||||
name_label_en?: string
|
||||
name_placeholder: string
|
||||
name_placeholder_en?: string
|
||||
phone_field_label: string
|
||||
phone_field_label_en?: string
|
||||
phone_placeholder: string
|
||||
phone_placeholder_en?: string
|
||||
note_label: string
|
||||
note_label_en?: string
|
||||
note_optional_label: string
|
||||
note_optional_label_en?: string
|
||||
note_placeholder: string
|
||||
note_placeholder_en?: string
|
||||
submit_label: string
|
||||
submit_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_title: string
|
||||
success_title_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
success_button_label: string
|
||||
success_button_label_en?: string
|
||||
}
|
||||
|
||||
export type NewsletterContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
placeholder: string
|
||||
placeholder_en?: string
|
||||
button_label: string
|
||||
button_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
}
|
||||
|
||||
export type SocialItem = { label: string; href: string; icon: string }
|
||||
@@ -121,12 +176,19 @@ export type SocialItem = { label: string; href: string; icon: string }
|
||||
export type FooterContent = {
|
||||
id: number
|
||||
about_text: string
|
||||
about_text_en?: string
|
||||
pages_heading: string
|
||||
pages_heading_en?: string
|
||||
services_heading: string
|
||||
services_heading_en?: string
|
||||
contact_heading: string
|
||||
contact_heading_en?: string
|
||||
copyright: string
|
||||
copyright_en?: string
|
||||
tagline: string
|
||||
tagline_en?: string
|
||||
socials: SocialItem[]
|
||||
socials_en?: SocialItem[] | null
|
||||
}
|
||||
|
||||
export interface SiteData {
|
||||
@@ -154,7 +216,7 @@ export interface SiteData {
|
||||
contact_section: ContactSectionContent
|
||||
newsletter: NewsletterContent
|
||||
footer: FooterContent
|
||||
seo_meta: { id: number; page_key: string; title: string; description: string }[]
|
||||
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
|
||||
}
|
||||
|
||||
interface SiteContextValue {
|
||||
@@ -244,13 +306,16 @@ export function useSiteData(): SiteContextValue {
|
||||
return useContext(SiteContext)
|
||||
}
|
||||
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند.
|
||||
// خروجی بومیسازیشده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی میشوند (با برگشت به فارسی).
|
||||
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده میشود.
|
||||
export function useSite(): SiteData {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
if (!data) {
|
||||
throw new Error('useSite باید پس از بارگذاری دادههای سایت استفاده شود')
|
||||
}
|
||||
return data
|
||||
return localizeSite(data, lang)
|
||||
}
|
||||
|
||||
const EMPTY_HEADING: SectionHeadingContent = {
|
||||
@@ -267,19 +332,25 @@ const EMPTY_HEADING: SectionHeadingContent = {
|
||||
/** تیتر و توضیح یک بخش بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function useSectionHeading(key: string): SectionHeadingContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||
}
|
||||
|
||||
/** هیرو بالای صفحه بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function usePageHero(key: string): PageHeroContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return (
|
||||
data?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
)
|
||||
}
|
||||
|
||||
/** برچسب فارسی وضعیت پروژه (done | progress) از پنل مدیریت */
|
||||
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
|
||||
export function useStatusLabel(key: string): string {
|
||||
const { data } = useContext(SiteContext)
|
||||
return data?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
@@ -3,23 +3,25 @@ import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
const { t, href } = useLang()
|
||||
return (
|
||||
<Link
|
||||
to={`/blog/${article.slug}`}
|
||||
to={href(`/blog/${article.slug}`)}
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`تصویر مقاله ${article.title}`}
|
||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<div className="absolute right-4 top-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
{article.category}
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,8 +46,8 @@ export function ArticleCard({ article }: { article: Article }) {
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
{t('readMore')}
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
/** یک پرسشوپاسخ مقاله — همان شکل داده ستون faqs مقاله در دیتابیس */
|
||||
export type ArticleFaq = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* پرسشوپاسخهای پایان مقاله — همان رفتار آکاردئونی بخش FAQ سایت، بدون ویرایش مدیریتی.
|
||||
* در سرچ کنسول/نتایج غنی گوگل، اسکیمای FAQPage آن جداگانه تزریق میشود.
|
||||
*/
|
||||
export function ArticleFaq({ faqs }: { faqs: ArticleFaq[] }) {
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
if (!faqs.length) return null
|
||||
|
||||
return (
|
||||
<section className="mt-16" aria-label="پرسشهای متداول مقاله">
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
<ul className="mt-2">
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
return (
|
||||
<li key={i} className="border-b border-ivory/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span
|
||||
className={`text-base font-bold md:text-lg ${
|
||||
isOpen ? 'text-copper' : 'text-ivory'
|
||||
}`}
|
||||
>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||
isOpen ? 'rotate-45' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 ps-2 text-sm font-light leading-9 text-ivory/60">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
|
||||
import { track } from '../analytics';
|
||||
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
type SseFrame = {
|
||||
content?: string;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
/** شناسه گفتگو برای هر نشست — در sessionStorage میماند تا رفرش صفحه تاریخچه را نگه دارد */
|
||||
function getThreadId(): string {
|
||||
const KEY = 'chat_thread_id';
|
||||
let id = sessionStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
sessionStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** خواندن فریمهای SSE از پاسخ fetch (استریم توکنبهتوکن) */
|
||||
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('پاسخ استریمی ندارد');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
|
||||
} catch {
|
||||
// فریم ناقص — نادیده بگیر
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatWidget() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const scrollEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const assistantIndexRef = useRef<number | null>(null);
|
||||
|
||||
// پیشنهاد پرسشها فقط یک بار برای هر بازشدن اول
|
||||
useEffect(() => {
|
||||
if (!open || suggestions.length > 0) return;
|
||||
fetch('/chat/suggestions')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
|
||||
.catch(() => setSuggestions([]));
|
||||
}, [open, suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [messages, open]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
const question = text.trim();
|
||||
if (!question || isLoading) return;
|
||||
track('chat-question')
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
setMessages((prev) => {
|
||||
const next: ChatMessage[] = [
|
||||
...prev,
|
||||
{ role: 'user', content: question },
|
||||
{ role: 'assistant', content: '', streaming: true },
|
||||
];
|
||||
assistantIndexRef.current = next.length - 1;
|
||||
return next;
|
||||
});
|
||||
|
||||
const patchAssistant = (patch: Partial<ChatMessage>) => {
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current;
|
||||
if (idx === null || !prev[idx]) return prev;
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...patch };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
|
||||
const response = await fetch(`/chat/stream?${params.toString()}`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
let accumulated = '';
|
||||
await readSse(response, (frame) => {
|
||||
if (frame.error) {
|
||||
patchAssistant({ content: frame.error, streaming: false, error: true });
|
||||
return;
|
||||
}
|
||||
if (frame.content) {
|
||||
accumulated += frame.content;
|
||||
patchAssistant({ content: accumulated });
|
||||
}
|
||||
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
||||
});
|
||||
patchAssistant({ streaming: false });
|
||||
} catch (err) {
|
||||
patchAssistant({
|
||||
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
|
||||
streaming: false,
|
||||
error: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دکمه شناور — گوشه راستپایین (دکمه تماس سمت چپ است) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((v) => {
|
||||
if (!v) track('chat-open');
|
||||
return !v;
|
||||
});
|
||||
}}
|
||||
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
|
||||
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
|
||||
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
|
||||
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* پنل گفتگو */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
|
||||
{/* سربرگ */}
|
||||
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
|
||||
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="بستن"
|
||||
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* پیامها */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="mt-6 text-center">
|
||||
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
|
||||
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
|
||||
سلام! هر سؤالی درباره خدمات، پروژهها و مقالات
|
||||
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
|
||||
دارید بپرسید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
|
||||
msg.role === 'user'
|
||||
? 'rounded-bl-md bg-copper text-char'
|
||||
: msg.error
|
||||
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
|
||||
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
|
||||
}`}>
|
||||
{msg.content || (msg.streaming ? '…' : '')}
|
||||
{msg.streaming && msg.content && (
|
||||
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={scrollEndRef} />
|
||||
</div>
|
||||
|
||||
{/* پیشنهاد پرسشها */}
|
||||
{suggestions.length > 0 && messages.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
|
||||
{suggestions.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
onClick={() => sendMessage(q)}
|
||||
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ورودی */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendMessage(input);
|
||||
}}
|
||||
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="پرسش خود را بنویسید…"
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
aria-label="ارسال"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
|
||||
<SendIcon className="h-4 w-4 -scale-x-100" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,18 @@ import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { API_URL } from '../config'
|
||||
import { track } from '../analytics'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
@@ -34,7 +37,7 @@ export function ContactSection() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -62,13 +65,13 @@ export function ContactSection() {
|
||||
const phoneValue = form.phone.trim()
|
||||
|
||||
if (nameValue.length < 3) {
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
|
||||
showErrorAlert(t('incompleteInfo'), t('nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
|
||||
if (!phoneValue || !phoneRegex.test(phoneValue)) {
|
||||
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
|
||||
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,20 +80,21 @@ export function ContactSection() {
|
||||
const res = await fetch(`${API_URL}/api/contact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
|
||||
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim(), website: form.website }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در ثبت درخواست', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('formCheck')
|
||||
showErrorAlert(t('submitErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
track('contact-submit')
|
||||
setSent(true)
|
||||
} catch {
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -150,6 +154,7 @@ export function ContactSection() {
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
dir="ltr"
|
||||
onClick={() => track('phone-click', { from: 'contact-section' })}
|
||||
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
|
||||
@@ -198,7 +203,7 @@ export function ContactSection() {
|
||||
className="mt-8 font-light"
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setForm({ name: '', phone: '', note: '' })
|
||||
setForm({ name: '', phone: '', note: '', website: '' })
|
||||
}}
|
||||
>
|
||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||
@@ -206,6 +211,17 @@ export function ContactSection() {
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||
{/* honeypot — خارج از دید؛ فقط رباتها آن را پر میکنند */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
|
||||
@@ -232,7 +248,7 @@ export function ContactSection() {
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light ltr:text-left rtl:text-right text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -262,7 +278,7 @@ export function ContactSection() {
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import React from 'react';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { track } from '../analytics';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
aria-label={t('callBrand')}
|
||||
onClick={() => track('phone-click', { from: 'floating' })}
|
||||
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
<span
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from './admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||
@@ -14,6 +15,7 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
@@ -38,7 +40,7 @@ export function Footer() {
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||
@@ -70,7 +72,7 @@ export function Footer() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
@@ -136,7 +138,7 @@ export function Footer() {
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>بازگشت به بالا</span>
|
||||
<span>{t('backToTop')}</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
|
||||
@@ -2,12 +2,15 @@ import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { useSite } from "../api";
|
||||
import { useLang } from "../i18n/lang";
|
||||
import { useFieldSaver } from "../admin/AdminContext";
|
||||
import { InlineEdit } from "./admin/InlineEdit";
|
||||
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||
import { LangToggle } from "./LangToggle";
|
||||
|
||||
export function Header() {
|
||||
const { brand, nav } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
@@ -40,7 +43,7 @@ export function Header() {
|
||||
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
|
||||
<div className="relative flex items-center">
|
||||
<Link
|
||||
to="/"
|
||||
to={href("/")}
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
@@ -52,16 +55,16 @@ export function Header() {
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
|
||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||
<nav aria-label={t("mainNav")} className="hidden xl:block">
|
||||
<ul className="flex items-center gap-7">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
|
||||
isActive ? "text-copper font-normal" : "text-ivory/80"
|
||||
@@ -102,12 +105,15 @@ export function Header() {
|
||||
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||
</a>
|
||||
|
||||
{/* تغییر زبان — فارسی/انگلیسی */}
|
||||
<LangToggle />
|
||||
|
||||
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
@@ -121,7 +127,7 @@ export function Header() {
|
||||
|
||||
{open && (
|
||||
<nav
|
||||
aria-label="ناوبری موبایل"
|
||||
aria-label={t("mobileNav")}
|
||||
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
|
||||
>
|
||||
<ul className="grid gap-1">
|
||||
@@ -129,7 +135,7 @@ export function Header() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
@@ -149,4 +155,4 @@ export function Header() {
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { LanguagesIcon } from 'lucide-react'
|
||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||
import { track } from '../analytics'
|
||||
|
||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||
export function LangToggle({ className = '' }: { className?: string }) {
|
||||
const { lang, t } = useLang()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const target = counterpartPath(pathname, lang)
|
||||
|
||||
const switchLang = () => {
|
||||
const next = lang === 'fa' ? 'en' : 'fa'
|
||||
track('lang-switch', { to: next })
|
||||
writeLangCookie(next)
|
||||
// پرش خودکار بازدید بعدی با انتخاب دستی همینجا گره نمیخورد
|
||||
sessionStorage.setItem('langAutoRedirect', '1')
|
||||
navigate(target)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchLang}
|
||||
aria-label={t('switchLangAria')}
|
||||
title={t('switchLangAria')}
|
||||
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
|
||||
>
|
||||
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
<span>{t('switchLang')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
@@ -15,6 +16,7 @@ type PageHeroProps = {
|
||||
|
||||
export function PageHero({ pageKey }: PageHeroProps) {
|
||||
const hero = usePageHero(pageKey)
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
|
||||
@@ -36,11 +38,11 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||
<span className="text-copper">{hero.title}</span>
|
||||
</nav>
|
||||
<motion.h1
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useSiteData } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||
const FALLBACK_LOGO = '/images/logo.png'
|
||||
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
|
||||
|
||||
export function Preloader() {
|
||||
const { data } = useSiteData()
|
||||
const { t } = useLang()
|
||||
const [done, setDone] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||
const name = data?.brand.name || FALLBACK_NAME
|
||||
const name = data?.brand.name || t('brandFallback')
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
@@ -43,7 +44,7 @@ export function Preloader() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
aria-live="polite"
|
||||
aria-label="در حال بارگذاری"
|
||||
aria-label={t('loading')}
|
||||
>
|
||||
<div className="relative h-36 w-36">
|
||||
<img
|
||||
|
||||
@@ -2,17 +2,19 @@ import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
const statusText = useStatusLabel(project.status)
|
||||
const { t, href, lang } = useLang()
|
||||
|
||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none">
|
||||
|
||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||
@@ -23,14 +25,14 @@ export function ProjectCard({ project }: any) {
|
||||
/>
|
||||
|
||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||
<div className={`absolute right-4 top-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
|
||||
<div className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||
<div className="absolute bottom-4 left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||
<ArrowLeftIcon className="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-0.5" />
|
||||
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,8 +49,9 @@ export function ProjectCard({ project }: any) {
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
||||
<span>سال: {project.year}</span>
|
||||
<span>{project.area} متر مربع</span>
|
||||
<span>{t('yearLabel')} {project.year}</span>
|
||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function SectionHeading({
|
||||
})
|
||||
|
||||
return (
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}>
|
||||
<InlineEdit
|
||||
as="h2"
|
||||
value={currentTitle}
|
||||
|
||||
@@ -102,6 +102,42 @@ const CLOSED_LINK_MODAL: LinkModalState = {
|
||||
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||
const URL_LOOKBACK = 300
|
||||
|
||||
/** آیا src تصویر خارج از میزبانی سایت است (مثل چسباندهشده از Outline)؟ */
|
||||
function isExternalImageSrc(src: string): boolean {
|
||||
if (!src || src.startsWith('data:')) return false
|
||||
if (src.startsWith('/')) return false // مسیر نسبی/داخلی
|
||||
try {
|
||||
return new URL(src).origin !== window.location.origin
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* میزبانی مجدد تصاویر خارجی یک HTML — هر <img> با نشانی خارجی از سرور دانلود و در
|
||||
* Object Storage خود سایت ذخیره و src آن بازنویسی میشود (چسباندن از Outline و مانند آن).
|
||||
* تصویری که درونریزیاش شکست بخورد با src اصلی میماند.
|
||||
*/
|
||||
async function rehostExternalImages(html: string): Promise<string> {
|
||||
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||
const imgs = Array.from(parsed.querySelectorAll('img[src]'))
|
||||
const externals = imgs.filter((img) => isExternalImageSrc(img.getAttribute('src') ?? ''))
|
||||
if (!externals.length) return html
|
||||
|
||||
await Promise.all(
|
||||
externals.map(async (img) => {
|
||||
const original = img.getAttribute('src') ?? ''
|
||||
try {
|
||||
const { url } = await adminApi.importImage(original)
|
||||
img.setAttribute('src', url)
|
||||
} catch {
|
||||
// نشانی خارجی میماند — شاید خودش کار کند
|
||||
}
|
||||
}),
|
||||
)
|
||||
return parsed.body.innerHTML
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
active = false,
|
||||
@@ -144,6 +180,8 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||
// مرحله نامگذاری درج تصویر از نوار ابزار — پیشفرض از نام خود فایل
|
||||
const [pendingImage, setPendingImage] = useState<{ file: File; name: string } | null>(null)
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: ARTICLE_EXTENSIONS,
|
||||
@@ -163,6 +201,22 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
// محتوای HTML با تصویر خارجی (Outline و مانند آن) — تصاویر به میزبانی سایت منتقل میشوند
|
||||
const html = event.clipboardData?.getData('text/html') ?? ''
|
||||
if (html.includes('<img')) {
|
||||
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
|
||||
isExternalImageSrc(img.getAttribute('src') ?? ''),
|
||||
)
|
||||
if (hasExternal) {
|
||||
event.preventDefault()
|
||||
void (async () => {
|
||||
const transformed = await rehostExternalImages(html)
|
||||
editor?.chain().focus().insertContent(transformed).run()
|
||||
})()
|
||||
return true
|
||||
}
|
||||
}
|
||||
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||
if (isUrlLike(text)) {
|
||||
@@ -186,6 +240,21 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
void uploadAndInsert(file)
|
||||
return true
|
||||
}
|
||||
const html = event.dataTransfer?.getData('text/html') ?? ''
|
||||
if (html.includes('<img')) {
|
||||
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
|
||||
isExternalImageSrc(img.getAttribute('src') ?? ''),
|
||||
)
|
||||
if (hasExternal) {
|
||||
event.preventDefault()
|
||||
void (async () => {
|
||||
const transformed = await rehostExternalImages(html)
|
||||
editor?.chain().focus().insertContent(transformed).run()
|
||||
})()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||
@@ -237,11 +306,11 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||
}, [editor, contentKey, doc])
|
||||
|
||||
async function uploadAndInsert(file: File) {
|
||||
async function uploadAndInsert(file: File, name?: string) {
|
||||
if (!editor) return
|
||||
setUploading(true)
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
const { url } = await adminApi.upload(file, name)
|
||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||
editor
|
||||
@@ -411,12 +480,72 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) void uploadAndInsert(file)
|
||||
if (file) {
|
||||
setPendingImage({ file, name: file.name.replace(/\.[^.]+$/, '') })
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* مرحله نامگذاری تصویر — نام خوانای ذخیرهسازی با یک کلیک تایید میشود */}
|
||||
{pendingImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="نام تصویر"
|
||||
onClick={() => setPendingImage(null)}
|
||||
>
|
||||
<div
|
||||
dir="rtl"
|
||||
className="w-full max-w-sm border border-ivory/15 bg-ink p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-sm font-bold text-copper">درج تصویر</h2>
|
||||
<label htmlFor="image-name-input" className="mt-4 block text-xs font-light text-ivory/60">
|
||||
نام فایل تصویر (برای نشانی ذخیرهسازی — اختیاری)
|
||||
</label>
|
||||
<input
|
||||
id="image-name-input"
|
||||
type="text"
|
||||
dir="auto"
|
||||
autoFocus
|
||||
value={pendingImage.name}
|
||||
onChange={(e) => setPendingImage({ ...pendingImage, name: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const { file, name } = pendingImage
|
||||
setPendingImage(null)
|
||||
void uploadAndInsert(file, name)
|
||||
}
|
||||
}}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<div className="mt-5 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingImage(null)}
|
||||
className="border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { file, name } = pendingImage
|
||||
setPendingImage(null)
|
||||
void uploadAndInsert(file, name)
|
||||
}}
|
||||
className="bg-copper px-6 py-2 text-xs font-bold text-char transition-transform hover:scale-105"
|
||||
>
|
||||
درج تصویر
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||
{linkModal.open && (
|
||||
<LinkModal
|
||||
|
||||
@@ -48,7 +48,10 @@ export function ImageField({
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const titleId = useId()
|
||||
const altId = useId()
|
||||
const nameId = useId()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
/** نام دلخواه فایل برای ذخیرهسازی خوانا — پیشفرض از نام فایل انتخابی ساخته میشود */
|
||||
const [fileBaseName, setFileBaseName] = useState('')
|
||||
|
||||
const showTitle = title !== undefined || !!onSaveTitle
|
||||
const showAlt = alt !== undefined || !!onSaveAlt
|
||||
@@ -73,13 +76,16 @@ export function ImageField({
|
||||
setUploading(true)
|
||||
try {
|
||||
await runSave(async () => {
|
||||
const { url } = await adminApi.upload(file)
|
||||
// نام ذخیرهسازی: نام تایپشده کاربر، وگرنه نام خود فایل بدون پسوند
|
||||
const name = fileBaseName.trim() || file.name.replace(/\.[^.]+$/, '')
|
||||
const { url } = await adminApi.upload(file, name)
|
||||
const ok = await onSave(url)
|
||||
if (!ok) throw new Error('ذخیره نشد')
|
||||
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||
if (showTitle) await titleField.commit()
|
||||
if (showAlt) await altField.commit()
|
||||
})
|
||||
setFileBaseName('')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -106,7 +112,7 @@ export function ImageField({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
@@ -120,6 +126,22 @@ export function ImageField({
|
||||
)}
|
||||
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||
</button>
|
||||
|
||||
{/* نام ذخیرهسازی فایل — خالی یعنی از نام خود فایل استفاده شود */}
|
||||
<div className="w-56">
|
||||
<label htmlFor={nameId} className="text-[11px] font-light text-ivory/50">
|
||||
نام فایل تصویر (اختیاری)
|
||||
</label>
|
||||
<input
|
||||
id={nameId}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={fileBaseName}
|
||||
onChange={(event) => setFileBaseName(event.target.value)}
|
||||
placeholder="مثلاً: elahieh-tower"
|
||||
className={META_FIELD_CLASS.replace('mt-1.5 ', '')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showTitle || showAlt) && (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
import { PlusIcon, XIcon } from 'lucide-react'
|
||||
import { CloudUploadIcon, PlusIcon, XIcon } from 'lucide-react'
|
||||
import { adminApi } from '../../admin/adminClient'
|
||||
import { useAdmin } from '../../admin/AdminContext'
|
||||
|
||||
type ImageAttrs = {
|
||||
src: string
|
||||
@@ -19,25 +21,39 @@ const INLINE_FIELD_CLASS =
|
||||
const ADD_CAPTION_CLASS =
|
||||
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
||||
|
||||
/** تصاویر میزبان خارجی (مثل چسباندهشده از Outline) قابل میزبانی مجدد هستند */
|
||||
function isExternalImage(src: string): boolean {
|
||||
if (!src) return false
|
||||
if (src.startsWith('/media/') || src.startsWith('/')) return false
|
||||
try {
|
||||
return new URL(src, window.location.origin).origin !== window.location.origin
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
||||
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||
*
|
||||
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
||||
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
||||
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
||||
* - دکمه X کپشن را کامل حذف میکند؛ «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||
* - Alt و Title تصویر (نام تصویر برای سئو/دسترسپذیری) پس از درج هم قابل ویرایشاند.
|
||||
* - تصویر میزبان خارجی دکمه «میزبانی مجدد» دارد: تصویر دانلود و در Object Storage
|
||||
* خود سایت ذخیره میشود (ترمیم تصاویر چسباندهشده از Outline).
|
||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||
*/
|
||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||
const { runSave } = useAdmin()
|
||||
const hasCaption = Boolean((caption ?? '').trim())
|
||||
const [captionOpen, setCaptionOpen] = useState(true)
|
||||
const [rehosting, setRehosting] = useState(false)
|
||||
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||
const focusOnOpen = useRef(false)
|
||||
const showCaption = captionOpen || hasCaption
|
||||
const external = isExternalImage(src)
|
||||
|
||||
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||
useEffect(() => {
|
||||
@@ -57,6 +73,16 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
||||
setCaptionOpen(true)
|
||||
}
|
||||
|
||||
/** میزبانی مجدد تصویر خارجی — دانلود از منبع و ذخیره در Object Storage سایت */
|
||||
const rehost = () => {
|
||||
if (rehosting) return
|
||||
setRehosting(true)
|
||||
void runSave(async () => {
|
||||
const { url } = await adminApi.importImage(src)
|
||||
updateAttributes({ src: url })
|
||||
}).finally(() => setRehosting(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
||||
@@ -77,6 +103,42 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{/* تصویر خارجی (مثل Outline) — منتقل به میزبانی سایت */}
|
||||
{external && (
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={rehost}
|
||||
disabled={rehosting}
|
||||
aria-label="میزبانی مجدد تصویر"
|
||||
title="تصویر از منبع خارجی دانلود و در سایت ذخیره میشود"
|
||||
className="absolute right-2 top-2 z-10 flex items-center gap-1.5 border border-copper/60 bg-ink/85 px-2.5 py-1 text-[11px] font-light text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char disabled:opacity-60"
|
||||
>
|
||||
<CloudUploadIcon className={`h-3.5 w-3.5 ${rehosting ? 'animate-pulse' : ''}`} />
|
||||
{rehosting ? 'در حال انتقال…' : 'میزبانی مجدد'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* نام تصویر — Alt و Title پس از درج هم قابل ویرایشاند */}
|
||||
<div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
|
||||
<input
|
||||
type="text"
|
||||
value={alt ?? ''}
|
||||
onChange={(event) => updateAttributes({ alt: event.target.value })}
|
||||
placeholder="نام تصویر / متن جایگزین (Alt)…"
|
||||
aria-label="متن جایگزین تصویر"
|
||||
className={INLINE_FIELD_CLASS}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={title ?? ''}
|
||||
onChange={(event) => updateAttributes({ title: event.target.value })}
|
||||
placeholder="عنوان تصویر (Title)…"
|
||||
aria-label="عنوان تصویر"
|
||||
className={INLINE_FIELD_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCaption ? (
|
||||
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||
<input
|
||||
|
||||
@@ -4,12 +4,14 @@ import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Articles() {
|
||||
const { articles } = useSite();
|
||||
const { href } = useLang();
|
||||
const heading = useSectionHeading('articles');
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -25,7 +27,7 @@ export function Articles() {
|
||||
return (
|
||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
||||
<p className="text-sm font-light text-copper">
|
||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||
</p>
|
||||
@@ -51,9 +53,9 @@ export function Articles() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -3,10 +3,12 @@ import { MoveHorizontalIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function BeforeAfter() {
|
||||
const { before_after } = useSite()
|
||||
const { t } = useLang()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState(52)
|
||||
const dragging = useRef(false)
|
||||
@@ -102,7 +104,7 @@ export function BeforeAfter() {
|
||||
<button
|
||||
type="button"
|
||||
role="slider"
|
||||
aria-label="مقایسه کانسپت و پروژه ساختهشده"
|
||||
aria-label={t('compareAria')}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(position)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
type FaqProps = {
|
||||
@@ -12,6 +13,7 @@ type FaqProps = {
|
||||
|
||||
export function Faq({ hideHeading = false }: FaqProps) {
|
||||
const { faqs } = useSite()
|
||||
const { t } = useLang()
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
@@ -22,8 +24,8 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<SectionHeading
|
||||
title="سوالات متداول"
|
||||
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||
title={t('fallbackFaqTitle')}
|
||||
lead={t('fallbackFaqLead')}
|
||||
headingKey="faq"
|
||||
/>
|
||||
</div>
|
||||
@@ -41,7 +43,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { ProjectCard } from '../ProjectCard'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { projects, status_labels } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
@@ -37,7 +39,7 @@ export function FeaturedProjects() {
|
||||
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||
|
||||
{/* دکمههای فیلتر مینیمال و شیشهای (بدون کادر) */}
|
||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه">
|
||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
||||
{FILTERS.map((filter, index) => {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
@@ -102,7 +104,7 @@ export function FeaturedProjects() {
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={heading.cta_href}
|
||||
to={href(heading.cta_href)}
|
||||
variant="outline"
|
||||
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
|
||||
>
|
||||
@@ -111,12 +113,12 @@ export function FeaturedProjects() {
|
||||
onSave={(v) =>
|
||||
save('section-headings', heading.id, { cta_label: v }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) h.cta_label = v
|
||||
if (h) (h as unknown as Record<string, unknown>)["cta_label"] = v
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 group-hover:-translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5" />
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang, formatNumber } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Gallery() {
|
||||
const { gallery } = useSite()
|
||||
const { t, lang } = useLang()
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
|
||||
const close = useCallback(() => setActive(null), [])
|
||||
@@ -30,8 +32,8 @@ export function Gallery() {
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<SectionHeading
|
||||
title="گالری تصاویر"
|
||||
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||
title={t('fallbackGalleryTitle')}
|
||||
lead={t('fallbackGalleryLead')}
|
||||
headingKey="gallery"
|
||||
align="center"
|
||||
/>
|
||||
@@ -44,7 +46,7 @@ export function Gallery() {
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
@@ -69,14 +71,14 @@ export function Gallery() {
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="نمایش تصویر"
|
||||
aria-label={t('viewImage')}
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute left-8 top-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
aria-label="بستن"
|
||||
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -87,10 +89,10 @@ export function Gallery() {
|
||||
e.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className="absolute right-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:right-10"
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
|
||||
aria-label={t('prevImage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6" />
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -98,10 +100,10 @@ export function Gallery() {
|
||||
e.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className="absolute left-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10"
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
|
||||
aria-label={t('nextImage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
|
||||
@@ -3,12 +3,14 @@ import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function Hero() {
|
||||
const { brand, hero } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const { scrollY } = useScroll()
|
||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||
@@ -21,11 +23,11 @@ export function Hero() {
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label={t('heroAria')}>
|
||||
{/* تصویر پسزمینه */}
|
||||
<motion.img
|
||||
src={brand.hero}
|
||||
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
||||
alt={t('heroImageAlt')}
|
||||
style={{ y: imageY }}
|
||||
className="absolute inset-0 h-[114%] w-full object-cover"
|
||||
/>
|
||||
@@ -62,7 +64,7 @@ export function Hero() {
|
||||
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
|
||||
>
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
|
||||
</motion.h1>
|
||||
|
||||
{/* زیرتیتر */}
|
||||
@@ -84,21 +86,21 @@ export function Hero() {
|
||||
>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={hero.cta_primary_href}
|
||||
to={href(hero.cta_primary_href)}
|
||||
variant="copper"
|
||||
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||
>
|
||||
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</span>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 group-hover:-translate-x-1">
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<ButtonLink
|
||||
to={hero.cta_secondary_href}
|
||||
to={href(hero.cta_secondary_href)}
|
||||
variant="outline"
|
||||
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
|
||||
>
|
||||
@@ -112,7 +114,7 @@ export function Hero() {
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="absolute bottom-28 left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:left-10"
|
||||
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
|
||||
>
|
||||
<p className="text-3xl font-bold text-copper font-mono">
|
||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||
@@ -126,7 +128,7 @@ export function Hero() {
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
<a
|
||||
href="#story"
|
||||
aria-label="رفتن به بخش بعدی"
|
||||
aria-label={t('scrollDown')}
|
||||
className="group absolute bottom-7 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/60 transition-colors duration-300 hover:text-copper"
|
||||
>
|
||||
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
|
||||
|
||||
@@ -4,15 +4,19 @@ import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { Button } from '../Button'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
import { API_URL } from '../../config'
|
||||
import { track } from '../../analytics'
|
||||
|
||||
export function Newsletter() {
|
||||
const { newsletter } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [email, setEmail] = useState('')
|
||||
const [website, setWebsite] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
@@ -29,7 +33,7 @@ export function Newsletter() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -58,13 +62,13 @@ export function Newsletter() {
|
||||
|
||||
if (!emailValue) {
|
||||
setStatus('error')
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
|
||||
showErrorAlert(t('incompleteInfo'), t('emailRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!emailRegex.test(emailValue)) {
|
||||
setStatus('error')
|
||||
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
|
||||
showErrorAlert(t('emailInvalidTitle'), t('emailInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,23 +77,24 @@ export function Newsletter() {
|
||||
const res = await fetch(`${API_URL}/api/newsletter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailValue }),
|
||||
body: JSON.stringify({ email: emailValue, website }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus('error')
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در عضویت', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('emailCheck')
|
||||
showErrorAlert(t('subscribeErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
track('newsletter-signup')
|
||||
setStatus('done')
|
||||
setEmail('')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -115,6 +120,17 @@ export function Newsletter() {
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
|
||||
{/* honeypot — خارج از دید؛ فقط رباتها آن را پر میکنند */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="newsletter-email" className="sr-only">
|
||||
{newsletter.placeholder}
|
||||
@@ -146,7 +162,7 @@ export function Newsletter() {
|
||||
disabled={sending}
|
||||
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<span>
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Services() {
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
<Reveal className="max-w-2xl text-start">
|
||||
|
||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user