Author SHA1 Message Date
Behrad 35a558db20 پاکسازی تصاویر موقت و به‌روزرسانی مراجع قبل/بعد در فرانت 2026-09-02 23:00:57 +03:30
Behrad 5a6cb51333 عکس‌های اصلی سایت — بخش ۴ (قبل/بعد + مراجع) 2026-09-02 23:00:09 +03:30
Behrad dc200099ba عکس‌های اصلی سایت — بخش ۳ (تصاویر ۱، ۲، ۵) 2026-09-02 22:59:19 +03:30
Behrad 0a55d5d0fe عکس‌های اصلی سایت — بخش ۲ (تماس و تصویر ۴) 2026-09-02 22:59:08 +03:30
Behrad 97dc728a4c عکس‌های اصلی سایت — بخش ۱ (لوگو و هیرو) 2026-09-02 22:58:17 +03:30
Behrad 87f38f1c0b تصاویر بدون کش طولانی — نمایش فوری تغییرات 2026-09-02 19:03:36 +03:30
Behrad 8dab39ee83 بازطراحی تصاویر محلی با رنگ‌های قابل‌دیدن (مسی/طلایی) 2026-09-02 18:56:02 +03:30
Behrad 32b6655a1e رفع باگ: بازگردانی Tailwind imports در CSS (حذف اشتباه در بهینه‌سازی) 2026-09-02 18:47:33 +03:30
Behrad 395aceb6f2 اضافه کردن گزارش بهینه‌سازی سرعت (PDF) 2026-09-02 12:22:10 +03:30
Behrad e76dbbf63d مستندسازی بهبود سرعت در README 2026-09-02 12:20:52 +03:30
Behrad e0ebae4817 بهبود سرعت: حذف کامل وابستگی‌های خارجی
- همه ۹ عکس سایت به‌صورت محلی (frontend/public/images) — بدون postimg/housedesign
- حذف فونت‌های خارجی (Google Fonts + fontcdn) — فونت سیستمی سریع
- favicon واقعی + کش nginx برای webp/ico
2026-09-01 23:04:57 +03:30
199 changed files with 1230 additions and 17648 deletions
-71
View File
@@ -1,71 +0,0 @@
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
# راه ساده‌تر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی می‌سازد.
# docker compose به‌طور خودکار این فایل را می‌خواند؛ بدون آن استک بالا نمی‌آید.
# --- رمزهای اصلی (اجباری) ---
# تولید کلید تصادفی: python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=change-me-to-a-long-random-string
POSTGRES_PASSWORD=change-me-db-password
MINIO_ROOT_USER=aramesh
MINIO_ROOT_PASSWORD=change-me-minio-password
ADMIN_USERNAME=admin
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
-7
View File
@@ -1,7 +0,0 @@
# اسکریپت‌های شل باید در لینوکس هم LF باشند — CRLF در bash می‌شکند
*.sh text eol=lf
*.yml text
*.yaml text
*.json text
Dockerfile text
*.py text
-47
View File
@@ -1,47 +0,0 @@
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
+1 -13
View File
@@ -9,15 +9,6 @@ env/
# Environment
.env
# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
monitoring/alertmanager/alertmanager.rendered.yml
# تنظیمات محلی هر ماشین
docker-compose.override.yml
# پشتیبان‌گیری محلی
backups/
# Node / Vite
node_modules/
dist/
@@ -28,11 +19,8 @@ dist/
.vscode/
.DS_Store
Thumbs.db
.zcode/
# Database
*.sqlite3
*.db
# تصاویر محلی — از پنل/کنسول MinIO مدیریت می‌شوند
frontend/public/images/
frontend/public/images/originals/
+54 -299
View File
@@ -2,329 +2,84 @@
مونوریپوی سایت گروه ساختمانی آرامش شامل:
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متن‌ها و تصاویر از API خوانده می‌شود)
- **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL (pgvector) + JWT + MinIO
- **`chatbot/`** — دستیار هوشمند (FastAPI + LangGraph + RAG با pgvector، پاسخ استریمی)
- **مانیتورینگ و آنالیتیکس** — Prometheus/Grafana/Loki و Umami به‌صورت لایه‌های compose
## معماری
```
Traefik (تنها نقطه ورود — 80/443 در تولید)
├── / → Caddy (فایل‌های استاتیک SPA)
├── /api → FastAPI (بک‌اند) + متریک /metrics فقط داخلی
├── /media → MinIO (باکت aramesh-media)
├── /chat → چت‌بات (SSE استریم پاسخ)
├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
└── analytics.* → Umami (آنالیتیکس محصول)
```
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانت‌اند هاردکد نشده؛
با یک درخواست `GET /api/site` بارگذاری و از پنل مدیریت ویرایش می‌شود
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره می‌شوند و با مسیر نسبی
`/media/...` برگردانده می‌شوند — بک‌اند ذخیره‌سازی بدون تغییر محتوای دیتابیس قابل تعویض است
- مسیریابی همه‌چیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
- شبکه‌های compose تفکیک شده‌اند: `edge` (سرویس‌های پشت Traefik) و `internal`
(دیتابیس/ذخیره‌سازی) — فرانت‌اند هیچ راه مستقیمی به دیتابیس ندارد
- **لاگ‌ها**: json-file با سقف ۱۰MB×۳ برای هر سرویس؛ علاوه بر آن Promtail همه را به
Loki می‌فرستد تا در Grafana جست‌وجو شوند
- **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده می‌شود)
- **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT
## ساختار
```
├── 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 # تنظیمات محلی این ماشین (در گیت نیست)
├── frontend/ # سایت React
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند)
│ ├── Dockerfile # بیلد Node + سرو با nginx
│ └── nginx.conf # پروکسی /api به بک‌اند
├── backend/ # بک‌اند FastAPI
│ ├── app/ # کد منبع
│ ├── requirements.txt
│ ├── Dockerfile
│ └── .env.example
└── docker-compose.yml # اجرای کامل با یک دستور
```
## اجرا با Docker
پیش‌نیاز: Docker Desktop. بیلد فرانت‌اند روی هاست انجام می‌شود (کانتینر فقط فایل‌های
`dist/` را سرو می‌کند) — `scripts/stack.sh` همین را به یک دستور تبدیل می‌کند:
## اجرا با Docker (ساده‌ترین راه)
```bash
cp .env.example .env # یا: bash scripts/gen-env.sh (رمزهای قوی تصادفی)
cd frontend && npm install && cd ..
bash scripts/stack.sh deploy # بیلد فرانت + بیلد و بالا آوردن استک + انتظار برای سلامت
docker compose up --build
```
- سایت: 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 (تصویر pgvector)، آبجکت‌استور MinIO و کش مدل‌های چت‌بات هر یک
در کانتینر جدا با volume دائمی
- بک‌اند هنگام شروع، جداول پایه را می‌سازد، **مهاجرت‌های Alembic** را اعمال می‌کند و
**فقط جدول‌های خالی** را seed می‌کند — ارتقای نسخه داده‌های فعلی را از بین نمی‌برد
- `minio-setup` باکت `aramesh-media` را می‌سازد و فقط **وقتی باکت خالی است** تصاویر
`seed-media/` را می‌ریزد — `db-init` هم دیتابیس‌های `umami` و `aramesh_chatbot` را
روی همان نمونهٔ Postgres می‌سازد
- سایت: http://localhost:5173
- مستندات API: http://localhost:8000/docs
- دیتابیس PostgreSQL در کانتینر جدا (داده‌ها در volume ذخیره می‌شوند)
- بک‌اند هنگام شروع، خودش جداول و داده‌های اولیه سایت را seed می‌کند
- nginx درخواست‌های `/api` را خودش به بک‌اند پروکسی می‌کند — نیازی به تنظیم CORS نیست
> نکته برای تازه‌واردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
> فایل‌های `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
>
> پایهٔ API فرانت‌اند در زمان بیلد از `VITE_API_URL` در `.env` خوانده می‌شود —
> در توسعه ویندوزیِ این ماشین `VITE_API_URL=http://localhost:19001` بگذارید.
## اجرای محلی (توسعه)
## انتشار واقعی (TLS)
پیش‌نیاز: رکوردهای DNS (`example.com`، `analytics.example.com` و اگر مانیتورینگ فعال
است `grafana.example.com`) به این سرور، سپس در `.env`: `DOMAIN`، `ACME_EMAIL` و
`SITE_URL=https://example.com`:
### بک‌اند
```bash
bash scripts/stack.sh deploy-prod
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
```
- Traefik گواهی Let's Encrypt می‌گیرد و تمدید می‌کند؛ همه درخواست‌های HTTP به HTTPS هدایت می‌شوند
- **پورت‌های مدیریتی 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
bash scripts/preview-mode.sh on # فعال‌سازی (بار اول نام کاربری/رمز می‌سازد و چاپ می‌کند)
bash scripts/stack.sh deploy-prod # اعمال
bash scripts/preview-mode.sh status # وضعیت فعلی
cd frontend
npm install
npm run dev # http://localhost:5173
```
- دامنه اصلی (`DOMAIN`) و دامنه دوم (`DOMAIN_ALIAS`) هر دو سرو می‌شوند و هر دو گیت دارند
- برای دادن دسترسی به کارفرما، همان نام کاربری/رمز مشترک را بدهید
- رمز مشترک در `.env` (`PREVIEW_AUTH_USERS`) می‌ماند — فقط یک‌بار چاپ می‌شود
### انتشار عمومی (برداشتن پیش‌نمایش)
```bash
bash scripts/preview-mode.sh off # حذف گیت ورود + اجازه ایندکس
bash scripts/stack.sh deploy-prod
```
بعد از این مرحله:
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`)
آدرس بک‌اند در `frontend/src/config.ts` از `VITE_API_URL` خوانده می‌شود؛
اگر تنظیم نشود پیش‌فرض `http://localhost:8000` است.
## امکانات بک‌اند
- **API عمومی**: برند، منو، هیرو، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم،
تایم‌لاین، آمار، دفاتر، مدل‌های سرمایه‌گذاری و... — همه در دیتابیس
- **فرم تماس** `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/...` برمی‌گردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
فشرده می‌شود (Pillow) تا سرعت صفحه حفظ شود
- **خروجی CSV** از نوار مدیریت: پیام‌های تماس و مشترکان خبرنامه (با BOM برای Excel)
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند
- **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
- همه محتوا در یک درخواست: `GET /api/site` (مقاله‌های پیش‌نویس در آن نمی‌آیند)
- **مهاجرت‌های اسکیما**: Alembic (`backend/migrations/`) — هر تغییر اسکیمای جدید باید
revision شود: `cd backend && alembic revision --autogenerate -m "..."` و پس از بازبینی
commit؛ در استارت کانتینر خودکار `upgrade head` اجرا می‌شود
- **تست‌ها**: `cd backend && .venv/Scripts/activate && pytest tests` (۲۴ تست — شامل
honeypot، محدودسازی نرخ و پاک‌سازی)
- **API عمومی**: برند، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم، تایم‌لاین، آمار و...
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...`
- همه محتوا در یک درخواست: `GET /api/site`
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
> بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
## ویرایش زنده سایت (دوقلوی دیجیتال)
ادمین‌ها نیازی به داشبورد جدا ندارند — خود سایت ویرایشگر است:
1. به `/admin/login` بروید و وارد شوید
2. نوار شناور پایین صفحه ظاهر می‌شود؛ **حالت ویرایش** را روشن کنید
3. روی هر متن کلیک کنید و ویرایش کنید — **خروج از کادر، بلافاصله ذخیره می‌شود**
(Enter ذخیره، Esc لغو)؛ وضعیت ذخیره در نوار پایین دیده می‌شود
4. آیتم‌های مجموعه‌ها (خدمات، پروژه‌ها، مقالات، نظرات، تیم و...) با هاور، دکمه‌های
ویرایش / حذف / جابه‌جایی ترتیب نشان می‌دهند؛ دکمه «افزودن» هم انتهای هر لیست هست
5. تصویرها از همان کشوها به MinIO آپلود می‌شوند
- در حالت ویرایش، لینک‌ها کلیک نمی‌شوند تا ویرایش متن ممکن باشد
- بازدیدکننده‌های عادی هیچ اثری از این لایه نمی‌بینند (بدون توکن، همه درخواست‌های
مدیریتی `401` برمی‌گردند)
- تمام تغییرها بلافاصله در PostgreSQL ثبت می‌شوند — با Ctrl+F5 هم می‌مانند
## وبلاگ — نوشتن با ویرایشگر WYSIWYG
مقالات با ویرایشگر غنی **TipTap** نوشته می‌شوند (همان تجربه Medium):
- در حالت ویرایش، روی هر مقاله در صفحه وبلاگ دکمه ویرایش را بزنید — کشوی نوشتن باز می‌شود
- تیتر (H2/H3)، بولد/ایتالیک/زیرخط، لیست نقطه‌ای و شماره‌ای، نقل‌قول، لینک و واگرد
- **درج تصویر داخل متن**: دکمه تصویر، یا چسباندن/کشیدن تصویر داخل نوشته — فایل مستقیم
در MinIO ذخیره و در متن قرار می‌گیرد
- راست‌به‌چپ کامل با فونت و تم سایت
- ذخیره خودکار: هر مکث کوتاه در تایپ، تغییرها را ذخیره می‌کند؛ بستن کشو هم ذخیره می‌کند
**پیش‌نویس و انتشار**: مقاله تازه همیشه **پیش‌نویس** شروع می‌شود — در سایت عمومی دیده
نمی‌شود و تا انتشار، تغییرهای خودکار آن چیزی را خراب نمی‌کند. با دکمه «انتشار در سایت»
عمومی می‌شود؛ دوباره می‌توان آن را به پیش‌نویس برگرداند. پیش‌نویس‌ها فقط برای ادمین
(در صفحه وبلاگ، با برچسب «پیش‌نویس») دیده می‌شوند و در `GET /api/site` هرگز نمی‌آیند.
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده
(پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف)
در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند. در زمان ذخیره، آدرس‌های
`href/src` سند در سمت سرور پاک‌سازی می‌شوند (`app/sanitize.py`) تا اسکیم‌های خطرناک
مثل `javascript:` هرگز ذخیره نشوند.
## سئو
- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug) — کلمه کلیدی هدف در دیتابیس
ذخیره می‌شود (فیلد `seo_keyphrase` مقاله)
- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) —
عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال می‌شود؛ اگر ردیف سئوی صفحه‌ای
هنوز ساخته نشده باشد، دکمه + همان‌جا آن را می‌سازد
- صفحه مقاله و پروژه به‌طور خودکار عنوان و توضیح متا را از محتوای خودشان می‌گیرند
- **تگ‌های OG و توییتر و canonical** برای همه صفحات از `src/seo.ts` ست می‌شود و
مقاله‌ها اسکیمای JSON-LD نوع Article می‌گیرند
- **robots.txt و sitemap.xml** از بک‌اند سرو می‌شوند و مقاله/پروژه‌های منتشرشده را
شامل می‌شوند (پیش‌نویس‌ها نمی‌آیند) — برای آدرس مطلق، `SITE_URL` را در `.env` بگذارید
- **متا برای خزنده‌های بدون جاوااسکریپت** (تلگرام، واتس‌اپ و...): بک‌اند صفحات
`/blog`، `/blog/{slug}`، `/projects` و `/projects/{slug}` را با همان `index.html`
فرانت‌اند سرو می‌کند و پیش از ارسال، title / description / OG / JSON-LD را از
دیتابیس داخل آن تزریق می‌کند (`app/seo_pages.py`) — بازدیدکننده‌های عادی همان SPA
همیشگی را می‌بینند
## پشتیبان‌گیری و بازگردانی
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است:
```bash
bash scripts/stack.sh backup # پشتیبان کامل: DB + تصاویر MinIO
docker compose --profile ops up -d db-backup # پشتیبان ساعتی خودکار DB (بدون cron میزبان)
```
- خروجی: `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 هم اجرا می‌شود
> در محیط واقعی حتماً `SECRET_KEY` و `ADMIN_PASSWORD` را تغییر دهید.
## نکات امنیتی
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
`${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید؛ هیچ رمز پیش‌فرضی در کد نیست
- رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
- توکن‌های JWT ۸ ساعت اعتبار دارند (stateless — تغییر `SECRET_KEY` همه را خارج می‌کند)
- همهٔ کانتینرها **بدون root** اجرا می‌شوند؛ کانتکست بیلد هر ایمیج با `.dockerignore`
باریک است و لاگ‌ها سقف دارند
- فرم‌های عمومی ضدربات‌اند (honeypot + محدودسازی نرخ در بک‌اند + سقف سیلاب در Traefik)
- کنسول MinIO و API مستقیمش در تولید فقط از `127.0.0.1` در دسترس‌اند (تونل SSH)
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
- مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند
- فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است)
- رمز عبور ادمین با bcrypt هش می‌شود
- توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
## تغییرات اخیر
### بهبود سرعت (کامیت e0ebae4)
- **حذف کامل وابستگی‌های خارجی**: همه عکس‌های سایت به‌صورت محلی در `frontend/public/images/` قرار گرفتند (بدون وابستگی به سرورهای خارجی مثل postimg و housedesign)
- **حذف فونت‌های خارجی** (Google Fonts و fontcdn که رندر صفحه را قفل می‌کردند) — استفاده از فونت سیستمی سریع
- **favicon واقعی** اضافه شد (قبلاً خراب بود)
- **کش ۳۰ روزه** برای تصاویر و فایل‌های استاتیک در nginx
- نتیجه: صفحه‌ها در چند میلی‌ثانیه لود می‌شوند و سایت هیچ درخواست خارجی ندارد
> اگر نسخه قبلی سایت را اجرا کرده‌اید، برای دریافت نسخه جدید یک بار `docker compose down -v` و سپس `docker compose up --build -d` را اجرا کنید.
-13
View File
@@ -1,13 +0,0 @@
# کانتکست بیلد باریک — .venv و کش‌های تست محلی جایی در تصویر ندارند
.venv/
venv/
tests/
.pytest_cache/
__pycache__/
**/__pycache__/
*.pyc
README.md
.env
.env.*
Dockerfile
.dockerignore
-12
View File
@@ -15,15 +15,3 @@ CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
# مشخصات ادمین پیش‌فرض که هنگام seed ساخته می‌شود
ADMIN_USERNAME=admin
ADMIN_PASSWORD=aramesh123
# Object Storage (MinIO) برای آپلود تصاویر پنل مدیریت
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=aramesh
S3_SECRET_KEY=change-me-minio-secret
S3_BUCKET=aramesh-media
# پیشوند عمومی مسیر تصاویر — پروکسی Reverse باید آن را به باکت برساند
MEDIA_PUBLIC_BASE=/media
# حداکثر حجم آپلود تصویر (مگابایت)
MEDIA_MAX_UPLOAD_MB=10
+3 -12
View File
@@ -1,4 +1,3 @@
# بک‌اند FastAPI — تصویر باریک و بدون root
FROM python:3.13-slim
WORKDIR /app
@@ -10,16 +9,8 @@ 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
# ساخت جداول پایه، مهاجرت‌های 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='*'"]
# ساخت جداول و seed (بدون حذف داده‌های موجود) و سپس اجرای سرور
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+7 -42
View File
@@ -1,15 +1,14 @@
# بک‌اند گروه ساختمانی آرامش (Aramesh Building Group API)
بک‌اند سایت گروه ساختمانی آرامش — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی).
بک‌اند سایت گروه ساختمانی آرامش — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
## امکانات
- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخش‌ها، هیرو صفحات، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، قبل/بعد، مدل‌های سرمایه‌گذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متن‌های سایت در دیتابیس است**
- **API عمومی محتوا**: برند، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، و بخش قبل/بعد
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیام‌های تماس و مشترکین
- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمی‌گردد
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد (فقط جدول‌های خالی پر می‌شوند — ارتقا بدون از دست رفتن داده)
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد
- مستندات تعاملی Swagger در مسیر `/docs`
## راه‌اندازی
@@ -86,13 +85,6 @@ Authorization: Bearer <access_token>
> نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید.
تغییر رمز (با توکن):
```
POST /api/auth/change-password
{ "current_password": "...", "new_password": "..." }
```
## فهرست انتهاپوینت‌ها
### عمومی (بدون نیاز به توکن)
@@ -104,8 +96,8 @@ POST /api/auth/change-password
| GET | `/api/services` | خدمات |
| GET | `/api/projects` | همه پروژه‌ها |
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
| GET | `/api/articles` | مقاله‌های منتشرشده (پیش‌نویس نمی‌آید) |
| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
| GET | `/api/articles` | همه مقالات |
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
| GET | `/api/testimonials` | نظرات مشتریان |
| GET | `/api/faqs` | سوالات متداول |
| GET | `/api/team` | اعضای تیم |
@@ -116,11 +108,7 @@ POST /api/auth/change-password
| GET | `/api/site` | **همه محتوا در یک درخواست** |
| POST | `/api/contact` | ثبت پیام تماس |
| POST | `/api/newsletter` | عضویت در خبرنامه |
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن — بعد از ۵ تلاش ناموفق در ۱۵ دقیقه، 429 |
| GET | `/robots.txt` | قوانین خزنده‌ها |
| GET | `/sitemap.xml` | نقشه سایت — مقاله/پروژه‌های منتشرشده |
| GET | `/blog/{slug}` | HTML صفحه مقاله با متای سئوی تزریق‌شده |
| GET | `/projects/{slug}` | HTML صفحه پروژه با متای سئوی تزریق‌شده |
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
### مدیریت (نیاز به توکن Bearer)
@@ -130,35 +118,12 @@ POST /api/auth/change-password
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش کامل / حذف |
| PATCH | `/api/admin/{resource}/{id}` | **ویرایش جزئی** — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند |
| POST | `/api/admin/{resource}/reorder` | ذخیره ترتیب: `{"ids": [3, 1, 2, ...]}` |
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف |
| GET | `/api/admin/messages` | پیام‌های تماس (با `?unread_only=true` فقط خوانده‌نشده‌ها) |
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خوانده‌شدن |
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
| POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` |
### بدنه مقاله — سند TipTap
`body` مقاله یک سند ساختاریافته TipTap (ProseMirror JSON) است: `{"type": "doc", "content": [...]}`.
- فقط ساختار اعتبارسنجی می‌شود (`type: "doc"` + `content` لیست)؛ امنیت رندر بر عهده
رندر استاتیک TipTap در فرانت‌اند است که فقط تگ‌های شناخته‌شده تولید می‌کند
- ردیف‌های قدیمی (لیست پاراگراف) با مهاجرت idempotent `migrate_article_bodies`
در startup/seed خودکار به سند تبدیل می‌شوند
- seed هم بدنه‌های لیستی را قبل از درج تبدیل می‌کند
| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact |
منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیس‌های موجود اضافه می‌شود.
### تصاویر و MinIO
فایل‌های آپلودشده در باکت `aramesh-media` روی MinIO ذخیره می‌شوند و با مسیر نسبی `/media/<سال>/<ماه>/<فایل>` برگردانده می‌شوند. Traefik مسیر `/media` را به MinIO می‌برد، پس URLها مستقل از بک‌اند ذخیره‌سازی هستند — بعداً برای مهاجرت به R2/S3 فقط تنظیمات را عوض کنید، نه محتوای دیتابیس را.
- کنسول MinIO: `http://localhost:9001` (کاربر/رمز از `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` در compose)
- تصاویر عمومی و قابل نمایش‌اند؛ آپلود فقط با توکن ادمین انجام می‌شود.
## اتصال به فرانت‌اند
-40
View File
@@ -1,40 +0,0 @@
[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
+17 -390
View File
@@ -1,112 +1,40 @@
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن 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, Form, HTTPException, Request, Response, UploadFile, status
from PIL import Image, ImageOps
from pydantic import BaseModel
from sqlalchemy import func
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import models, schemas
from ..auth import create_access_token, get_current_admin, hash_password, verify_password
from ..config import settings
from ..auth import create_access_token, get_current_admin, verify_password
from ..database import get_db
from ..rate_limit import clear as clear_rate_limit
from ..rate_limit import ensure_allowed, record_failure
from ..schemas import (
ArticleIn,
ArticleOut,
ArticlePatch,
BeforeAfterIn,
BeforeAfterOut,
BeforeAfterPatch,
BrandIn,
BrandOut,
BrandPatch,
ChangePasswordIn,
ContactSectionIn,
ContactSectionOut,
ContactSectionPatch,
ContactMessageOut,
FaqIn,
FaqOut,
FaqPatch,
FooterIn,
FooterOut,
FooterPatch,
GalleryImageIn,
GalleryImageOut,
GalleryImagePatch,
HeroIn,
HeroOut,
HeroPatch,
InvestmentModelIn,
InvestmentModelOut,
InvestmentModelPatch,
LoginIn,
MessageReadIn,
NavItemIn,
NavItemOut,
NavItemPatch,
NewsletterSectionIn,
NewsletterOut,
NewsletterSectionPatch,
OfficeIn,
OfficeOut,
OfficePatch,
PageHeroIn,
PageHeroOut,
PageHeroPatch,
ProcessStepIn,
ProcessStepOut,
ProcessStepPatch,
ProjectIn,
ProjectOut,
ProjectPatch,
ReorderIn,
SeoMetaIn,
SeoMetaOut,
SeoMetaPatch,
SectionHeadingIn,
SectionHeadingOut,
SectionHeadingPatch,
ServiceIn,
ServiceOut,
ServicePatch,
StatIn,
StatOut,
StatPatch,
StatusLabelIn,
StatusLabelOut,
StatusLabelPatch,
StoryIn,
StoryOut,
StoryPatch,
SubscriberOut,
TeamMemberIn,
TeamMemberOut,
TeamMemberPatch,
TestimonialIn,
TestimonialOut,
TestimonialPatch,
TimelineItemIn,
TimelineItemOut,
TimelineItemPatch,
TokenOut,
WhyUsItemIn,
WhyUsItemOut,
WhyUsItemPatch,
)
# روتر عمومی برای ورود — بدون نیاز به توکن
@@ -117,52 +45,26 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
@auth_router.post("/auth/login", response_model=TokenOut)
def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
ensure_allowed(request)
def login(payload: LoginIn, db: Session = Depends(get_db)):
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
if user is None or not verify_password(payload.password, user.password_hash):
record_failure(request)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="نام کاربری یا رمز عبور اشتباه است",
)
clear_rate_limit(request)
return TokenOut(access_token=create_access_token(user.username))
@auth_router.post("/auth/change-password")
def change_password(
payload: ChangePasswordIn,
admin: models.AdminUser = Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد."""
if not verify_password(payload.current_password, admin.password_hash):
raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است")
admin.password_hash = hash_password(payload.new_password)
db.commit()
return {"ok": True}
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیب‌بندی) روی روتر مدیریت."""
def register_crud(path: str, model, out_schema, create_schema) -> None:
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
@router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db)):
query = db.query(model)
if hasattr(model, "position"):
query = query.order_by(model.position, model.id)
else:
query = query.order_by(model.id)
return query.all()
return db.query(model).order_by(model.id).all()
@router.post(f"/{path}", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)):
item = model(**payload.model_dump())
if hasattr(model, "position"):
# آیتم جدید در انتهای لیست قرار می‌گیرد
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
db.add(item)
try:
db.commit()
@@ -194,38 +96,6 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
db.refresh(item)
return item
if patch_schema is not None:
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
"""ویرایش جزئی — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند (برای ویرایش زنده)."""
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(item, key, value)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
db.refresh(item)
return item
@router.post(f"/{path}/reorder", response_model=list[out_schema])
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
"""ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی."""
if not hasattr(model, "position"):
raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند")
items = db.query(model).filter(model.id.in_(payload.ids)).all()
if len(items) != len(payload.ids):
raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
by_id = {item.id: item for item in items}
for pos, item_id in enumerate(payload.ids):
by_id[item_id].position = pos
db.commit()
return db.query(model).order_by(model.position, model.id).all()
@router.delete(f"/{path}/{{item_id}}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)):
item = db.get(model, item_id)
@@ -236,260 +106,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
# CRUD محتوا
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد
ALLOWED_IMAGE_TYPES = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"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()
ext = ALLOWED_IMAGE_TYPES.get(content_type)
if ext is None:
raise HTTPException(
status_code=415,
detail="فرمت فایل پشتیبانی نمی‌شود (مجاز: jpg، png، webp، gif، avif)",
)
max_bytes = settings.media_max_upload_mb * 1024 * 1024
data = await file.read()
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
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
url = _store_image(data, content_type, display_name)
return {
"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
)
register_crud("brand", models.Brand, BrandOut, BrandIn)
register_crud("services", models.Service, ServiceOut, ServiceIn)
register_crud("projects", models.Project, ProjectOut, ProjectIn)
register_crud("articles", models.Article, ArticleOut, ArticleIn)
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
register_crud("faqs", models.Faq, FaqOut, FaqIn)
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
register_crud("stats", models.Stat, StatOut, StatIn)
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
# پیام‌های تماس
+5 -40
View File
@@ -1,48 +1,17 @@
"""انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه.
"""انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه."""
هر فرم یک دامنهٔ پنهان (honeypot) دارد: ربات‌ها همهٔ فیلدها را پُر می‌کنند؛
در آن حالت پاسخ موفقِ قلابی برمی‌گردد و چیزی ذخیره نمی‌شود.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
from ..rate_limit import contact_limiter, newsletter_limiter
from .. import models
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, request: Request, db: Session = Depends(get_db)):
contact_limiter.hit(request)
if payload.website:
return _fake_contact(payload)
def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
message = models.ContactMessage(
name=payload.name.strip(),
phone=payload.phone.strip(),
@@ -55,11 +24,7 @@ def submit_contact(payload: ContactIn, request: Request, db: Session = Depends(g
@router.post("/newsletter", response_model=SubscriberOut, status_code=201)
def subscribe(payload: NewsletterIn, request: Request, db: Session = Depends(get_db)):
newsletter_limiter.hit(request)
if payload.website:
return _fake_subscriber(payload)
def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)):
email = str(payload.email).lower()
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
+21 -88
View File
@@ -1,13 +1,8 @@
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
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 (
@@ -27,44 +22,9 @@ from ..schemas import (
router = APIRouter()
@router.get("/live")
def live():
"""زنده‌بودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمی‌شود."""
return {"status": "ok"}
@router.get("/health")
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}
def health():
return {"status": "ok", "app": "Aramesh Building Group API"}
@router.get("/brand", response_model=BrandOut)
@@ -77,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
@router.get("/services", response_model=list[ServiceOut])
def get_services(db: Session = Depends(get_db)):
return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
return db.query(models.Service).all()
@router.get("/projects", response_model=list[ProjectOut])
def get_projects(db: Session = Depends(get_db)):
return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
return db.query(models.Project).order_by(models.Project.id).all()
@router.get("/projects/{slug}", response_model=ProjectOut)
@@ -95,21 +55,12 @@ def get_project(slug: str, db: Session = Depends(get_db)):
@router.get("/articles", response_model=list[ArticleOut])
def get_articles(db: Session = Depends(get_db)):
return (
db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
)
return db.query(models.Article).order_by(models.Article.id).all()
@router.get("/articles/{slug}", response_model=ArticleOut)
def get_article(slug: str, db: Session = Depends(get_db)):
article = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
article = db.query(models.Article).filter(models.Article.slug == slug).first()
if article is None:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return article
@@ -117,32 +68,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
@router.get("/testimonials", response_model=list[TestimonialOut])
def get_testimonials(db: Session = Depends(get_db)):
return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
return db.query(models.Testimonial).all()
@router.get("/faqs", response_model=list[FaqOut])
def get_faqs(db: Session = Depends(get_db)):
return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
return db.query(models.Faq).all()
@router.get("/team", response_model=list[TeamMemberOut])
def get_team(db: Session = Depends(get_db)):
return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
return db.query(models.TeamMember).all()
@router.get("/timeline", response_model=list[TimelineItemOut])
def get_timeline(db: Session = Depends(get_db)):
return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
@router.get("/stats", response_model=list[StatOut])
def get_stats(db: Session = Depends(get_db)):
return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
return db.query(models.Stat).all()
@router.get("/why-us", response_model=list[WhyUsItemOut])
def get_why_us(db: Session = Depends(get_db)):
return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
return db.query(models.WhyUsItem).all()
@router.get("/before-after", response_model=BeforeAfterOut)
@@ -158,32 +109,14 @@ def get_site(db: Session = Depends(get_db)):
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند."""
return {
"brand": db.query(models.Brand).first(),
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
"hero": db.query(models.Hero).first(),
"story": db.query(models.StorySection).first(),
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
# فقط مقاله‌های منتشرشده — پیش‌نویس‌ها از انتهای مدیریتی خوانده می‌شوند
"articles": db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all(),
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
"faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
"stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
"why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
"services": db.query(models.Service).all(),
"projects": db.query(models.Project).order_by(models.Project.id).all(),
"articles": db.query(models.Article).order_by(models.Article.id).all(),
"testimonials": db.query(models.Testimonial).all(),
"faqs": db.query(models.Faq).all(),
"team": db.query(models.TeamMember).all(),
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
"stats": db.query(models.Stat).all(),
"why_us": db.query(models.WhyUsItem).all(),
"before_after": db.query(models.BeforeAfter).first(),
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(),
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(),
"offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(),
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(),
"status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
"contact_section": db.query(models.ContactSectionContent).first(),
"newsletter": db.query(models.NewsletterSectionContent).first(),
"footer": db.query(models.FooterContent).first(),
"seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
}
+1 -20
View File
@@ -13,29 +13,10 @@ 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"
# ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاه‌تر ریسک نشت را کم می‌کند
access_token_expire_minutes: int = 480
access_token_expire_minutes: int = 1440
cors_origins: list[str] = ["*"]
admin_username: str = "admin"
admin_password: str = "aramesh123"
# Object Storage (MinIO / هر S3 سازگار) برای آپلود تصاویر
s3_endpoint: str = "http://localhost:9000"
s3_access_key: str = "aramesh"
s3_secret_key: str = "aramesh-minio-secret"
s3_bucket: str = "aramesh-media"
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی می‌کند
media_public_base: str = "/media"
media_max_upload_mb: int = 10
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
site_url: str = ""
# مسیر بیلد فرانت‌اند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
frontend_dist: str = "../frontend/dist"
# پیش‌نمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکس‌شدنی.
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
# هدر noindex روی همه پاسخ‌ها + robots.txt مسدودکننده.
preview_mode: bool = False
settings = Settings()
-11
View File
@@ -1,11 +0,0 @@
"""ساخت جداول پایه (baseline) — پیش از مهاجرت‌های Alembic.
روی دیتابیس تازه، همهٔ جدول‌ها از مدل‌های فعلی ساخته می‌شوند؛ روی دیتابیس موجود
بی‌اثر است (create_all جدول‌های موجود را دست نمی‌زند). از این‌جا به بعد تغییرات
اسکیما فقط با revisionهای Alembic اعمال می‌شوند.
"""
from . import models # noqa: F401 — ثبت مدل‌ها
from .database import Base, engine
Base.metadata.create_all(bind=engine)
-26
View File
@@ -1,16 +1,11 @@
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 .seo_pages import router as seo_router
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال می‌شوند
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام می‌دهد.
Base.metadata.create_all(bind=engine)
app = FastAPI(
@@ -31,32 +26,11 @@ 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=["فرم‌ها"])
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
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("/")
-319
View File
@@ -1,319 +0,0 @@
"""مهاجرت‌های سبک دیتابیس — DDL و تبدیل داده‌های قدیمی؛ همه idempotent.
هم main.py (startup) و هم seed.py این‌ها را صدا می‌زنند تا هر مسیر اجرایی
دیتابیس موجود را بدون از دست رفتن داده ارتقا دهد.
"""
from sqlalchemy import text
from .database import SessionLocal, engine
# جدول‌های مجموعه‌ای که ستون position دارند
POSITION_TABLES = [
"services", "projects", "articles", "testimonials", "faqs", "team",
"timeline", "stats", "why_us", "investment_models", "process_steps",
"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."""
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
for table in POSITION_TABLES:
conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
)
# مقالات موجود منتشر شده‌اند؛ فقط مقاله‌های تازه می‌توانند پیش‌نویس باشند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'published'")
)
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:
"""تبدیل لیست پاراگراف‌های متنی (فرمت قدیمی بدنه مقاله) به سند TipTap."""
content = []
for para in paragraphs:
if isinstance(para, str) and para.strip():
content.append({
"type": "paragraph",
"content": [{"type": "text", "text": para}],
})
if not content:
content = [{"type": "paragraph"}]
return {"type": "doc", "content": content}
def migrate_article_bodies() -> None:
"""تبدیل بدنه مقالات از لیست پاراگراف به سند JSON — idempotent."""
from . import models # lazy — جلوگیری از import حلقه‌ای
db = SessionLocal()
try:
changed = 0
for article in db.query(models.Article).all():
if isinstance(article.body, list):
article.body = paragraphs_to_doc(article.body)
changed += 1
db.commit()
if changed:
print(f"مقالات تبدیل‌شده به فرمت سند: {changed}")
finally:
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()
+1 -336
View File
@@ -1,5 +1,4 @@
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
@@ -11,10 +10,6 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc)
# ستون‌های *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
# مقدار خالی یعنی «ترجمه‌ای ثبت نشده» و فرانت‌اند به نسخه فارسی برمی‌گردد.
class AdminUser(Base):
__tablename__ = "admin_users"
@@ -28,7 +23,6 @@ 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="")
@@ -36,162 +30,101 @@ 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):
__tablename__ = "services"
position: Mapped[int] = mapped_column(Integer, default=0)
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")
class Project(Base):
__tablename__ = "projects"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "articles"
position: Mapped[int] = mapped_column(Integer, default=0)
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="")
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")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
body: Mapped[list] = mapped_column(JSON, default=list)
class Testimonial(Base):
__tablename__ = "testimonials"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "faqs"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "team"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "timeline"
position: Mapped[int] = mapped_column(Integer, default=0)
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="")
class Stat(Base):
__tablename__ = "stats"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "why_us"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
@@ -199,279 +132,11 @@ 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):
__tablename__ = "nav_items"
position: Mapped[int] = mapped_column(Integer, default=0)
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))
class Hero(Base):
"""محتوای هیرو صفحه اصلی — رکورد یگانه."""
__tablename__ = "hero"
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):
"""بخش «درباره ما» در خانه و صفحه درباره — رکورد یگانه."""
__tablename__ = "story"
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):
"""تیتر و توضیح بخش‌ها — فرانت‌اند بر اساس key آن را پیدا می‌کند."""
__tablename__ = "section_headings"
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="")
class PageHeroContent(Base):
"""هیرو بالای هر صفحه — key: about | services | projects | investment | blog | faq | contact."""
__tablename__ = "page_heroes"
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="")
class InvestmentModel(Base):
__tablename__ = "investment_models"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "process_steps"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "offices"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "gallery_images"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
"""برچسب فارسی وضعیت پروژه — key: done | progress."""
__tablename__ = "status_labels"
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):
"""بخش فرم تماس — رکورد یگانه."""
__tablename__ = "contact_section"
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):
"""بخش خبرنامه — رکورد یگانه."""
__tablename__ = "newsletter"
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):
"""متن‌های فوتر — رکورد یگانه."""
__tablename__ = "footer"
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):
"""عنوان و توضیح سئوی هر صفحه — page_key: home / about / services / ..."""
__tablename__ = "seo_meta"
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):
-100
View File
@@ -1,100 +0,0 @@
"""محدودسازی نرخ درخواست — پنجرهٔ لغزان درون‌حافظه‌ای.
برای ورود ادمین (بر پایه تلاش ناموفق) و فرم‌های عمومی (بر پایه هر ارسال)
استفاده می‌شود. شمارنده‌ها در حافظهٔ همین پروسه‌اند — با ری‌استارت پاک می‌شوند؛
به همین دلیل uvicorn تک‌کارگر نگه داشته شده است.
"""
import time
from fastapi import HTTPException, Request, status
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"
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)
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="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید",
headers={"Retry-After": str(retry_after)},
)
def record_failure(request: Request) -> None:
_failed.setdefault(client_ip(request), []).append(time.monotonic())
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="تعداد درخواست‌ها زیاد است — کمی بعد دوباره امتحان کنید",
)
-58
View File
@@ -1,58 +0,0 @@
"""پاک‌سازی ورودی‌های غنی در زمان ذخیره.
بدنه مقاله‌ها سند 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)
+4 -595
View File
@@ -1,9 +1,7 @@
from datetime import datetime
from typing import Literal, Optional
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
from .sanitize import sanitize_doc
from pydantic import BaseModel, ConfigDict, EmailStr, Field
# ---------- پایه ----------
@@ -12,16 +10,12 @@ 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
@@ -29,15 +23,12 @@ 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
@@ -45,89 +36,56 @@ 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
body: list[str]
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
@@ -135,239 +93,23 @@ 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
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):
@@ -393,14 +135,10 @@ 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)
# ---------- ورودی‌های پنل مدیریت ----------
@@ -416,14 +154,8 @@ class TokenOut(BaseModel):
token_type: str = "bearer"
class ChangePasswordIn(BaseModel):
current_password: str
new_password: str = Field(min_length=8, max_length=128)
class BrandIn(BaseModel):
name: str = ""
name_en: str = ""
logo: str = ""
hero: str = ""
contact_bg: str = ""
@@ -431,407 +163,84 @@ 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) معتبر از نظر ساختار باشد و
آدرس‌های 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 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)
body: list[str] = []
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):
is_read: bool = True
# ---------- اسکیماهای PATCH (ویرایش جزئی — همه فیلدها اختیاری) ----------
def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
"""از اسکیمای ورودی، اسکیمای PATCH می‌سازد؛ فیلدهای ارسال‌نشده دست‌نخورده می‌مانند."""
fields = {fname: (Optional[f.annotation], None) for fname, f in model_cls.model_fields.items()}
return create_model(name, **fields)
BrandPatch = _make_patch("BrandPatch", BrandIn)
ServicePatch = _make_patch("ServicePatch", ServiceIn)
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)
TimelineItemPatch = _make_patch("TimelineItemPatch", TimelineItemIn)
StatPatch = _make_patch("StatPatch", StatIn)
WhyUsItemPatch = _make_patch("WhyUsItemPatch", WhyUsItemIn)
BeforeAfterPatch = _make_patch("BeforeAfterPatch", BeforeAfterIn)
NavItemPatch = _make_patch("NavItemPatch", NavItemIn)
HeroPatch = _make_patch("HeroPatch", HeroIn)
StoryPatch = _make_patch("StoryPatch", StoryIn)
SectionHeadingPatch = _make_patch("SectionHeadingPatch", SectionHeadingIn)
PageHeroPatch = _make_patch("PageHeroPatch", PageHeroIn)
InvestmentModelPatch = _make_patch("InvestmentModelPatch", InvestmentModelIn)
ProcessStepPatch = _make_patch("ProcessStepPatch", ProcessStepIn)
OfficePatch = _make_patch("OfficePatch", OfficeIn)
GalleryImagePatch = _make_patch("GalleryImagePatch", GalleryImageIn)
StatusLabelPatch = _make_patch("StatusLabelPatch", StatusLabelIn)
ContactSectionPatch = _make_patch("ContactSectionPatch", ContactSectionIn)
NewsletterSectionPatch = _make_patch("NewsletterSectionPatch", NewsletterSectionIn)
FooterPatch = _make_patch("FooterPatch", FooterIn)
class ReorderIn(BaseModel):
"""ترتیب جدید آیتم‌های یک مجموعه بر اساس id."""
ids: list[int]
+40 -82
View File
@@ -1,79 +1,16 @@
"""اسکریپت seed دیتابیس.
اجرا:
python -m app.seed # فقط جدول‌های خالی پر می‌شوند
python -m app.seed # فقط اگر دیتابیس خالی باشد
python -m app.seed --force # حذف کامل داده‌ها و ساخت مجدد
پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدول‌های جدید یا خالی
seed می‌شوند و داده‌های قبلی دست‌نخورده می‌مانند.
"""
import argparse
from typing import Any
from . import models, seed_data, seed_data_en
from . import models, seed_data
from .auth import hash_password
from .config import settings
from .database import Base, SessionLocal, engine
from .migrations import paragraphs_to_doc, run_all as run_migrations
# (مدل، داده‌های اولیه، برچسب گزارش)
CONTENT: list[tuple[Any, list[dict], str]] = [
(models.Brand, [seed_data.BRAND], "برند"),
(models.Hero, [seed_data.HERO], "هیرو صفحه اصلی"),
(models.StorySection, [seed_data.STORY], "داستان ما"),
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, "تیتر بخش‌ها"),
(models.PageHeroContent, seed_data.PAGE_HEROES, "هیرو صفحات"),
(models.NavItem, seed_data.NAV_ITEMS, "منوی ناوبری"),
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, "مدل‌های سرمایه‌گذاری"),
(models.ProcessStep, seed_data.PROCESS_STEPS, "مراحل فرآیند کار"),
(models.Office, seed_data.OFFICES, "دفاتر"),
(models.GalleryImage, seed_data.GALLERY_IMAGES, "گالری تصاویر"),
(models.StatusLabel, seed_data.STATUS_LABELS, "برچسب وضعیت پروژه"),
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], "بخش تماس"),
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], "خبرنامه"),
(models.FooterContent, [seed_data.FOOTER], "فوتر"),
(models.Service, seed_data.SERVICES, "خدمات"),
(models.Stat, seed_data.STATS, "آمار"),
(models.Project, seed_data.PROJECTS, "پروژه‌ها"),
(models.Article, seed_data.ARTICLES, "مقالات"),
(models.Testimonial, seed_data.TESTIMONIALS, "نظرات"),
(models.Faq, seed_data.FAQS, "سوالات متداول"),
(models.TeamMember, seed_data.TEAM, "تیم"),
(models.TimelineItem, seed_data.TIMELINE, "تایم‌لاین"),
(models.WhyUsItem, seed_data.WHY, "چرا آرامش"),
(models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"),
(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:
@@ -84,22 +21,32 @@ def seed(force: bool = False) -> None:
print("جداول قبلی حذف شدند.")
Base.metadata.create_all(bind=engine)
run_migrations()
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT):
if db.query(model).count() > 0:
continue
for idx, row in enumerate(rows):
# فایل فارسی مرجع است؛ فقط فیلدهای *_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:
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 not force and db.query(models.Service).count() > 0:
print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force")
return
db.add(models.Brand(**seed_data.BRAND))
for item in seed_data.SERVICES:
db.add(models.Service(**item))
for item in seed_data.STATS:
db.add(models.Stat(**item))
for item in seed_data.PROJECTS:
db.add(models.Project(**item))
for item in seed_data.ARTICLES:
db.add(models.Article(**item))
for item in seed_data.TESTIMONIALS:
db.add(models.Testimonial(**item))
for item in seed_data.FAQS:
db.add(models.Faq(**item))
for item in seed_data.TEAM:
db.add(models.TeamMember(**item))
for item in seed_data.TIMELINE:
db.add(models.TimelineItem(**item))
for item in seed_data.WHY:
db.add(models.WhyUsItem(**item))
db.add(models.BeforeAfter(**seed_data.BEFORE_AFTER))
if db.query(models.AdminUser).count() == 0:
db.add(
@@ -112,8 +59,19 @@ def seed(force: bool = False) -> None:
db.commit()
for model, _, label in CONTENT:
print(f" {label}: {db.query(model).count()} رکورد")
counts = {
"خدمات": db.query(models.Service).count(),
"پروژه‌ها": db.query(models.Project).count(),
"مقالات": db.query(models.Article).count(),
"نظرات": db.query(models.Testimonial).count(),
"سوالات متداول": db.query(models.Faq).count(),
"تیم": db.query(models.TeamMember).count(),
"تایم‌لاین": db.query(models.TimelineItem).count(),
"آمار": db.query(models.Stat).count(),
"چرا آرامش": db.query(models.WhyUsItem).count(),
}
for label, count in counts.items():
print(f" {label}: {count} رکورد")
print("✔ seed با موفقیت انجام شد.")
finally:
db.close()
+10 -177
View File
@@ -2,9 +2,9 @@
BRAND = {
"name": "گروه ساختمانی آرامش",
"logo": "https://i.postimg.cc/XYNrsHsM/Chat-GPT-Image-Aug-6-2026-04-34-46-PM.png",
"hero": "https://i.postimg.cc/J0bKgQVg/Gemini-Generated-Image-mdh969mdh969mdh9.png",
"contact_bg": "https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png",
"logo": "/images/logo.png",
"hero": "/images/hero.png",
"contact_bg": "/images/contact.png",
"phone": "۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰",
"phone_href": "tel:+982188742210",
"email": "info@arameshbuildinggroup.com",
@@ -12,11 +12,11 @@ BRAND = {
}
IMAGES = [
"https://i.postimg.cc/wTQ0QFkG/Gemini-Generated-Image-1tjszd1tjszd1tjs.png",
"https://i.postimg.cc/1zfBPS1G/Gemini-Generated-Image-at69yhat69yhat69.png",
"https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png",
"https://i.postimg.cc/4xFvW0HX/Gemini-Generated-Image-n7mhonn7mhonn7mh.png",
"https://i.postimg.cc/N036cr4s/Gemini-Generated-Image-ytbhvvytbhvvytbh.png",
"/images/img1.png",
"/images/img2.png",
"/images/contact.png",
"/images/img4.png",
"/images/img5.png",
]
SERVICES = [
@@ -154,8 +154,8 @@ PROJECTS = [
BEFORE_AFTER = {
"title": "از کانسپت تا خلق واقعیت",
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل می‌شود.",
"before": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_before.webp",
"after": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_after.webp",
"before": "/images/before.webp",
"after": "/images/after.webp",
"before_label": "کانسپت اولیه",
"after_label": "پروژه تکمیل‌شده",
}
@@ -289,170 +289,3 @@ FAQS = [
{"question": "خدمات پس از تحویل شامل چه مواردی می‌شود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دوره‌ای سالانه و تضمین اجرای نما و عایق‌کاری."},
{"question": "پروژه‌ها در چه شهرهایی اجرا می‌شوند؟", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژه‌ای نیز در جزیره کیش در دست ساخت است."},
]
# ---------- محتوای قبلاً هاردکد‌شده در فرانت‌اند (الان در دیتابیس) ----------
NAV_ITEMS = [
{"label": "صفحه اصلی", "href": "/"},
{"label": "درباره ما", "href": "/about"},
{"label": "خدمات", "href": "/services"},
{"label": "پروژه‌ها", "href": "/projects"},
{"label": "سرمایه‌گذاری", "href": "/investment"},
{"label": "وبلاگ", "href": "/blog"},
{"label": "سوالات متداول", "href": "/faq"},
{"label": "تماس با ما", "href": "/contact"},
]
HERO = {
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
"title": "ساختمانی که آرامش را معماری می‌کند",
"title_highlight": "معماری",
"subtitle": "طراحی، ساخت و سرمایه‌گذاری در پروژه‌های مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده می‌شود و در سال‌ها دوام می‌آورد.",
"badge_value": "۴۷",
"badge_label": "پروژه تحویل‌شده در بیست‌ویک سال فعالیت حرفه‌ای",
"cta_primary_label": "مشاهده پروژه‌ها",
"cta_primary_href": "/projects",
"cta_secondary_label": "مشاوره سرمایه‌گذاری",
"cta_secondary_href": "/contact",
"scroll_label": "اسکرول کنید",
}
STORY = {
"title": "درباره ی ما",
"paragraphs": [
"گروه ساختمانی آرامش با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعه‌های شناخته‌شده ساخت‌وساز لوکس در ایران است. در تمام این سال‌ها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.",
"هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز می‌شود؛ سپس تیم طراحی، سازه و اجرا در یک فرآیند واحد آن را تا تحویل کلید پیش می‌برند.",
],
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی می‌سازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
"stat1_value": "۹۴٪",
"stat1_label": "تحویل به‌موقع پروژه‌ها",
"stat2_value": "۵ سال",
"stat2_label": "پشتیبانی پس از تحویل",
"link_label": "داستان کامل ما",
"link_href": "/about",
"bento": [
{"src": IMAGES[0], "alt": "نمای برج مسکونی آرامش در تهران"},
{"src": IMAGES[1], "alt": "آتریوم مرکزی مجتمع تجاری پارسه"},
{"src": IMAGES[2], "alt": "ویلاهای ساخته‌شده در سواحل شمال"},
{"src": IMAGES[3], "alt": "نمای شبانه برج دوقلوی نیاوران"},
],
}
SECTION_HEADINGS = [
{"key": "services", "title": "خدمات", "lead": "از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام کنترل کیفیت."},
{"key": "featured_projects", "title": "پروژه‌های شاخص", "lead": "کارهایی که بیشترین چیز را درباره استاندارد ما می‌گویند؛ چه آن‌هایی که تحویل شده‌اند و چه آن‌هایی که همین امروز در حال ساخت‌اند.", "cta_label": "همه پروژه‌ها", "cta_href": "/projects"},
{"key": "gallery", "title": "گالری تصاویر", "lead": "قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش."},
{"key": "timeline", "title": "مسیر ما", "lead": "از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."},
{"key": "why_us", "title": "چرا گروه ساختمانی آرامش", "lead": "شش دلیلی که کارفرمایان و سرمایه‌گذاران بار دوم هم به ما برمی‌گردند.", "image": IMAGES[3]},
{"key": "testimonials", "title": "نظرات مشتریان"},
{"key": "team", "title": "تیم ما", "lead": "کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."},
{"key": "articles", "label": "وبلاگ", "title": "آخرین مقالات", "lead": "یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار می‌گذرد.", "cta_label": "همه مقالات", "cta_href": "/blog"},
{"key": "faq", "title": "سوالات متداول", "lead": "پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."},
{"key": "investment_models", "title": "مدل‌های همکاری", "lead": "بسته به میزان سرمایه، افق زمانی و سطح ریسکی که می‌پذیرید، یکی از این سه مسیر مناسب شماست."},
{"key": "process", "title": "فرآیند کار ما", "lead": "چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید."},
]
PAGE_HEROES = [
{"key": "about", "title": "درباره ما", "lead": "گروه ساختمانی آرامش مجموعه‌ای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساخت‌وساز لوکس فعالیت می‌کند.", "image": IMAGES[3]},
{"key": "services", "title": "خدمات", "lead": "هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهره‌برداری پوشش می‌دهند.", "image": IMAGES[1]},
{"key": "projects", "title": "پروژه‌ها", "lead": "مجموعه‌ای از برج‌های مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژه‌های تحویل‌شده تا کارگاه‌های فعال امروز.", "image": IMAGES[0]},
{"key": "investment", "title": "سرمایه‌گذاری", "lead": "سه مسیر روشن برای ورود به بازار ساخت‌وساز لوکس، با ساختار مالی شفاف و گزارش‌دهی منظم.", "image": IMAGES[4]},
{"key": "blog", "title": "وبلاگ", "lead": "یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار می‌گذرد.", "image": IMAGES[2]},
{"key": "faq", "title": "سوالات متداول", "lead": "پیش از تماس، شاید پاسخ پرسش شما همین‌جا باشد. اگر نبود، خوشحال می‌شویم مستقیم صحبت کنیم.", "image": IMAGES[1]},
{"key": "contact", "title": "تماس با ما", "lead": "برای مشاوره ساخت، سرمایه‌گذاری یا بازدید از پروژه‌ها، تیم ما در سه شهر آماده پاسخ‌گویی است.", "image": IMAGES[1]},
]
INVESTMENT_MODELS = [
{
"title": "مشارکت در ساخت",
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک می‌شوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
"points": ["ارزیابی رایگان زمین", "قرارداد شفاف با ضمانت اجرا", "گزارش ماهانه پیشرفت"],
},
{
"title": "صندوق پروژه‌محور",
"desc": "سرمایه‌گذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبه‌شده در پایان دوره.",
"points": ["حداقل دوره ۲۴ ماه", "گزارش ارزش‌گذاری فصلی", "امکان انتقال واحد سرمایه"],
},
{
"title": "پیش‌خرید واحد",
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحله‌ای.",
"points": ["پرداخت مرحله‌ای متناسب با پیشرفت", "امکان شخصی‌سازی پلان", "تضمین زمان تحویل"],
},
]
PROCESS_STEPS = [
{"step": "۰۱", "title": "مطالعه و امکان‌سنجی", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه."},
{"step": "۰۲", "title": "طراحی و فاز اجرایی", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشه‌های اجرایی."},
{"step": "۰۳", "title": "اجرا و کنترل کیفیت", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه."},
{"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهره‌برداری و پنج سال پشتیبانی فنی."},
]
OFFICES = [
{"city": "تهران", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸"},
{"city": "اصفهان", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"},
{"city": "نوشهر", "address": "بلوار دریا، دفتر پروژه دریاکنار", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶"},
]
GALLERY_IMAGES = [
{"src": IMAGES[0], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۱"},
{"src": IMAGES[1], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۲"},
{"src": IMAGES[2], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۳"},
{"src": IMAGES[3], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۴"},
{"src": IMAGES[4], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۵"},
{"src": IMAGES[1], "alt": "تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ۶"},
]
STATUS_LABELS = [
{"key": "done", "label": "انجام شده"},
{"key": "progress", "label": "در حال انجام"},
]
CONTACT_SECTION = {
"label": "تماس با ما",
"title": "پروژه‌تان را با ما در میان بگذارید",
"lead": "شماره‌تان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس می‌گیرد.",
"address_label": "دفتر مرکزی",
"phone_label": "تلفن",
"email_label": "ایمیل",
"name_label": "نام",
"name_placeholder": "نام و نام خانوادگی",
"phone_field_label": "شماره تلفن",
"phone_placeholder": "09xx xxx xxxx",
"note_label": "توضیحات",
"note_optional_label": "(اختیاری)",
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
"submit_label": "ارسال درخواست مشاوره",
"sending_label": "در حال ارسال…",
"success_title": "درخواست شما ثبت شد",
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما به‌زودی با شما تماس می‌گیرند.",
"success_button_label": "ثبت درخواست جدید",
}
NEWSLETTER = {
"title": "خبرنامه گروه ساختمانی آرامش",
"lead": "هر ماه یک ایمیل: پروژه‌های جدید، فرصت‌های سرمایه‌گذاری و یادداشت‌های تیم معماری.",
"placeholder": "نشانی ایمیل شما",
"button_label": "عضویت",
"sending_label": "در حال ارسال…",
"success_text": "عضویت شما ثبت شد.",
}
FOOTER = {
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمان‌هایی می‌سازد که قرار است چند نسل در آن‌ها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایه‌گذاری شفاف.",
"pages_heading": "صفحات",
"services_heading": "خدمات",
"contact_heading": "ارتباط با ما",
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
"tagline": "طراحی و ساخت با دقتِ معماری",
"socials": [
{"label": "اینستاگرام", "href": "#", "icon": "instagram"},
{"label": "لینکدین", "href": "#", "icon": "linkedin"},
{"label": "تلگرام", "href": "#", "icon": "telegram"},
],
}
SEO_META = [
{"page_key": key, "title": "", "description": ""}
for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
]
-580
View File
@@ -1,580 +0,0 @@
"""ترجمه انگلیسی داده‌های اولیه سایت — ساختار یک‌به‌یک با 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."},
]
-479
View File
@@ -1,479 +0,0 @@
"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریق‌شده.
فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند.
این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق می‌کند —
بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند.
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو می‌شود؛
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی می‌شوند.
"""
import html
import json
import re
from pathlib import Path
from fastapi import APIRouter, Depends, Response
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from . import models
from .config import settings
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:
if not path:
return ""
if path.startswith(("http://", "https://")):
return path
site = settings.site_url.rstrip("/")
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")
except OSError:
return None
def _doc_text(doc: object, limit: int = 300) -> str:
"""متن ساده سند TipTap — برای noscript و پیش‌نمایش."""
words: list[str] = []
def walk(node: object) -> None:
if not isinstance(node, dict):
return
if node.get("type") == "text" and isinstance(node.get("text"), str):
words.append(node["text"])
for child in node.get("content") or []:
walk(child)
if isinstance(doc, dict):
for block in doc.get("content") or []:
walk(block)
text = " ".join(words).strip()
return text[:limit] + ("…" if len(text) > limit else "")
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 (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 = [
f'<meta name="description" content="{esc(description)}" />',
f'<meta property="og:title" content="{esc(title)}" />',
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:
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
if jsonld:
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
tags.append(f'<script type="application/ld+json">{payload}</script>')
block = "\n ".join(tags)
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_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" {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, lang: str = "fa") -> str:
brand = db.query(models.Brand).first()
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)):
# پیش‌نمایش خصوصی — نقشه سایت خالی تا هیچ نشانی‌ای اعلام نشود
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()
]
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(_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" '
'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")
# ---------- صفحات وبلاگ ----------
@router.get("/blog", response_class=HTMLResponse)
def blog_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "blog")
return _shell(
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
description=meta.description if meta else "",
path="/blog",
)
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 = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None:
# مقاله ناموجود یا پیش‌نویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان می‌دهد
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: object = {
"@context": "https://schema.org",
"@type": "Article",
"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}"),
}
# پرسش‌وپاسخ مقاله → اسکیمای 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"{title} | {brand}",
description=excerpt,
path=f"/blog/{article.slug}",
og_image=article.image,
og_type="article",
jsonld=jsonld,
noscript=noscript,
)
# ---------- صفحات پروژه ----------
@router.get("/projects", response_class=HTMLResponse)
def projects_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "projects")
return _shell(
title=meta.title if meta and meta.title else "پروژه‌ها | " + _brand_name(db),
description=meta.description if meta else "",
path="/projects",
)
@router.get("/projects/{slug}", response_class=HTMLResponse)
def 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), 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=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")
-49
View File
@@ -1,49 +0,0 @@
"""محیط 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()
-25
View File
@@ -1,25 +0,0 @@
"""${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"}
@@ -1,25 +0,0 @@
"""نقطه شروع زنجیره مهاجرت‌ها — جداول پایه با 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
-4
View File
@@ -1,4 +0,0 @@
# وابستگی‌های توسعه و تست — pip install -r requirements-dev.txt
-r requirements.txt
pytest==8.3.4
httpx==0.27.2
+9 -21
View File
@@ -1,21 +1,9 @@
# نسخه‌ها دقیق پین شده‌اند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
# ارتقا: نسخه را دستی بالا ببرید و روی محیط آماده‌سازی تست کنید.
fastapi==0.115.8
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
psycopg[binary]==3.2.3
pydantic==2.10.4
pydantic-settings==2.6.1
PyJWT==2.10.1
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
fastapi>=0.115.8
uvicorn[standard]>=0.34.0
sqlalchemy>=2.0.36
psycopg[binary]>=3.2.3
pydantic>=2.10.4
pydantic-settings>=2.6.1
PyJWT>=2.10.1
bcrypt>=4.2.1
email-validator>=2.2.0
-97
View File
@@ -1,97 +0,0 @@
"""پیکربندی تست‌ها — دیتابیس SQLite فایل‌محور و داده‌های حداقلی.
متغیرهای محیطی پیش از ایمپورت app تنظیم می‌شوند تا settings همان‌ها را بخواند.
"""
import os
import pathlib
os.environ["DATABASE_URL"] = "sqlite:///./test.db?check_same_thread=false"
os.environ["SECRET_KEY"] = "test-secret-key"
os.environ["ADMIN_USERNAME"] = "admin"
os.environ["ADMIN_PASSWORD"] = "test-admin-pass-1"
os.environ["SITE_URL"] = "https://test.example"
import pytest
from fastapi.testclient import TestClient
from app import models
from app.auth import hash_password
from app.config import settings
from app.database import Base, SessionLocal, engine
from app.main import app
TEST_DB = pathlib.Path("test.db")
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
@pytest.fixture(scope="session", autouse=True)
def seed_db(tmp_path_factory):
# اتصالات ساخت جدول در ایمپورت ماژول باید بسته شوند تا فایل قدیمی حذف شود
engine.dispose()
if TEST_DB.exists():
TEST_DB.unlink()
Base.metadata.create_all(bind=engine)
# dist ساختگی فرانت‌اند برای تست تزریق متا در صفحات جزئیات
dist = tmp_path_factory.mktemp("dist")
(dist / "index.html").write_text(
"<!doctype html><html><head><title>Shell</title></head>"
"<body><div id=\"root\"></div></body></html>",
encoding="utf-8",
)
settings.frontend_dist = str(dist)
settings.site_url = "https://test.example"
db = SessionLocal()
try:
db.add(models.AdminUser(username="admin", password_hash=hash_password("test-admin-pass-1")))
db.add(models.Brand(name="گروه ساختمانی آرامش"))
db.add(
models.Article(
slug="published-post", title="مقاله منتشرشده", status="published",
excerpt="چکیده مقاله", body=DOC, position=1,
)
)
db.add(
models.Article(
slug="draft-post", title="پیش‌نویس", status="draft",
excerpt="چکیده پیش‌نویس", body=DOC, position=2,
)
)
db.commit()
finally:
db.close()
yield
db.close()
engine.dispose()
if TEST_DB.exists():
TEST_DB.unlink()
@pytest.fixture(autouse=True)
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()
def client():
return TestClient(app)
@pytest.fixture()
def auth_client(client):
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
assert res.status_code == 200, res.text
token = res.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
client.headers.pop("Authorization", None)
-579
View File
@@ -1,579 +0,0 @@
"""تست‌های API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیش‌نویس مقاله و سئو."""
import re
import socket
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
# ---------- عمومی ----------
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
slugs = [a["slug"] for a in res.json()["articles"]]
assert "published-post" in slugs
assert "draft-post" not in slugs
def test_public_articles_hide_drafts(client):
res = client.get("/api/articles")
assert all(a["status"] == "published" for a in res.json())
assert client.get("/api/articles/draft-post").status_code == 404
def test_admin_endpoints_reject_anonymous(client):
assert client.get("/api/admin/articles").status_code == 401
# ---------- احراز هویت ----------
def test_login_success_and_failure(client):
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}).status_code
== 401
)
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
assert res.status_code == 200
assert res.json()["access_token"]
def test_change_password(auth_client, client):
res = auth_client.post(
"/api/auth/change-password",
json={"current_password": "wrong", "new_password": "brand-new-pass-9"},
)
assert res.status_code == 400
res = auth_client.post(
"/api/auth/change-password",
json={"current_password": "test-admin-pass-1", "new_password": "brand-new-pass-9"},
)
assert res.status_code == 200
# رمز قدیمی رد و رمز جدید قبول می‌شود
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}).status_code
== 401
)
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}).status_code
== 200
)
# بازگرداندن رمز برای تست‌های بعدی
res = client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"})
auth = {"Authorization": f"Bearer {res.json()['access_token']}"}
res = client.post(
"/api/auth/change-password",
json={"current_password": "brand-new-pass-9", "new_password": "test-admin-pass-1"},
headers=auth,
)
assert res.status_code == 200
def test_login_rate_limited(client):
for _ in range(5):
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 429
assert "Retry-After" in res.headers
# ---------- CRUD مدیریت ----------
def test_article_crud_and_draft_flow(auth_client, client):
created = auth_client.post(
"/api/admin/articles",
json={"slug": "flow-post", "title": "مقاله جریان", "body": DOC},
)
assert created.status_code == 201, created.text
article = created.json()
# مقاله تازه پیش‌نویس است و در سایت عمومی دیده نمی‌شود
assert article["status"] == "draft"
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"seo_keyphrase": "ساختمان"})
assert res.json()["seo_keyphrase"] == "ساختمان"
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"status": "published"})
assert res.status_code == 200
assert "flow-post" in [a["slug"] for a in client.get("/api/articles").json()]
assert auth_client.delete(f"/api/admin/articles/{article['id']}").status_code == 204
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
def test_service_roundtrip_and_reorder(auth_client):
ids = []
for name in ("خدمت الف", "خدمت ب"):
res = auth_client.post("/api/admin/services", json={"title": name})
assert res.status_code == 201
ids.append(res.json()["id"])
res = auth_client.post("/api/admin/services/reorder", json={"ids": ids[::-1]})
assert res.status_code == 200
ordered = [s["id"] for s in res.json() if s["id"] in ids]
assert ordered == ids[::-1]
res = auth_client.patch(f"/api/admin/services/{ids[0]}", json={"desc": "توضیح تازه"})
assert res.json()["desc"] == "توضیح تازه"
for i in ids:
assert auth_client.delete(f"/api/admin/services/{i}").status_code == 204
def test_seo_meta_create_and_patch(auth_client):
res = auth_client.post(
"/api/admin/seo-meta", json={"page_key": "test-page", "title": "عنوان", "description": "توضیح"}
)
assert res.status_code == 201
meta_id = res.json()["id"]
res = auth_client.patch(f"/api/admin/seo-meta/{meta_id}", json={"title": "عنوان تازه"})
assert res.json()["title"] == "عنوان تازه"
assert auth_client.delete(f"/api/admin/seo-meta/{meta_id}").status_code == 204
# ---------- سئو ----------
def test_robots_txt(client):
res = client.get("/robots.txt")
assert res.status_code == 200
assert "Disallow: /admin" in res.text
assert "Sitemap: https://test.example/sitemap.xml" in res.text
def test_sitemap_includes_published_only(client):
res = client.get("/sitemap.xml")
assert res.status_code == 200
assert "/blog/published-post" in res.text
assert "/blog/draft-post" not in res.text
assert res.text.startswith("<?xml")
def test_head_injection_for_article(client):
res = client.get("/blog/published-post")
assert res.status_code == 200
assert "مقاله منتشرشده" in res.text
assert 'property="og:title"' in res.text
assert "application/ld+json" in res.text
assert "https://test.example/blog/published-post" in res.text
def test_head_not_injected_for_draft(client):
res = client.get("/blog/draft-post")
assert res.status_code == 200
assert "پیش‌نویس" not in res.text # عنوان پیش‌نویس افشا نمی‌شود
assert "application/ld+json" not in res.text
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", "")
-12
View File
@@ -1,12 +0,0 @@
# کانتکست بیلد باریک
__pycache__/
**/__pycache__/
*.pyc
.venv/
venv/
.env
.env.*
README.md
Dockerfile
.dockerignore
test_pure.py
-28
View File
@@ -1,28 +0,0 @@
# سرویس چت‌بات — این مقادیر را در .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
-29
View File
@@ -1,29 +0,0 @@
# سرویس چت‌بات — پایتون + مدل‌های بازیابی (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=*"]
-121
View File
@@ -1,121 +0,0 @@
# دستیار هوشمند سایت (چت‌بات)
سرویس گفتگوی محتوای سایت «گروه ساختمانی آرامش» — بازدیدکننده به فارسی سؤال می‌پرسد و
دستیار **فقط از روی محتوای خود سایت** (مقاله‌ها، پروژه‌ها، خدمات، سؤالات متداول، اطلاعات تماس و…)
پاسخ می‌دهد؛ اگر پاسخ در محتوا نباشد، به‌جای ساخت جواب، صادقانه اعلام می‌کند.
این سرویس پورت خط لولهٔ پروژهٔ `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) است
-1
View File
@@ -1 +0,0 @@
"""سرویس چت‌بات سایت آرامش — بازیابی ترکیبی روی محتوای PostgreSQL."""
-284
View File
@@ -1,284 +0,0 @@
"""سرویس چت‌بات سایت آرامش — 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)
-57
View File
@@ -1,57 +0,0 @@
"""تنظیمات سرویس چت‌بات — از متغیرهای محیطی یا فایل .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()
-315
View File
@@ -1,315 +0,0 @@
"""خواندن محتوای سایت از 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 ""
-20
View File
@@ -1,20 +0,0 @@
"""مدل امبدینگ — با لود تنبل تا اولین استفاده (برای تست‌های سبک، 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,
)
-55
View File
@@ -1,55 +0,0 @@
"""ایندکس ترکیبی روی 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
-79
View File
@@ -1,79 +0,0 @@
"""نرمال‌سازی متن فارسی و توکن‌سازی برای جست‌وجوی کلیدواژه (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
-191
View File
@@ -1,191 +0,0 @@
"""گراف گفتگو (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)
-20
View File
@@ -1,20 +0,0 @@
# سرویس چت‌بات — بازیابی ترکیبی روی محتوای سایت
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
-50
View File
@@ -1,50 +0,0 @@
"""مدل ری‌رنکر 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()
-165
View File
@@ -1,165 +0,0 @@
"""بازیابی ترکیبی — پورت خط لوله 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]
-70
View File
@@ -1,70 +0,0 @@
"""تست‌های خالص (بدون مدل/دیتابیس) برای ماژول‌های چت‌بات — اجرا: 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")
-211
View File
@@ -1,211 +0,0 @@
"""ذخیره‌ساز برداری 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]
-189
View File
@@ -1,189 +0,0 @@
# لایه مانیتورینگ — 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:
-139
View File
@@ -1,139 +0,0 @@
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۳.
# پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور.
#
# 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
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL را در .env تنظیم کنید}
- --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"
volumes: !override
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt
# 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:
- traefik.enable=true
- 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:
+16 -347
View File
@@ -1,375 +1,44 @@
# مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید:
# 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)،
# /chat (چت‌بات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
traefik:
# 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:
# pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چت‌بات
image: pgvector/pgvector:pg17
image: postgres:17-alpine
restart: unless-stopped
logging: *default-logging
environment:
POSTGRES_USER: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
POSTGRES_PASSWORD: aramesh
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: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:
# در تولید، 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.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
# فقط وقتی باکت خالی است — تا هر deploy، کپی تکراری تصویرها را تکرار نکند.
minio-setup:
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
MINIO_BUCKET: aramesh-media
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 &&
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 تنظیم کنید}
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost:5173","http://127.0.0.1:5173"]}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
S3_BUCKET: aramesh-media
MEDIA_PUBLIC_BASE: /media
# نشانی عمومی سایت — برای sitemap و آدرس مطلق OG (مثل https://aramesh.example)
SITE_URL: ${SITE_URL:-}
# بیلد فرانت‌اند برای تزریق متای سئو در صفحات /blog و /projects
FRONTEND_DIST: /srv/frontend
volumes:
- ./frontend/dist:/srv/frontend:ro
networks: [edge, internal]
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza
SECRET_KEY: change-me-to-a-long-random-string
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
ADMIN_USERNAME: admin
ADMIN_PASSWORD: aramesh123
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
timeout: 5s
retries: 5
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
# مسیرهای /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
ports:
- "8000:8000"
# فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend:
build: ./frontend
build:
context: ./frontend
args:
VITE_API_URL: ""
restart: unless-stopped
logging: *default-logging
depends_on:
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:RELEASE.2025-04-08T15-39-49Z
profiles: ["tools"]
depends_on:
minio:
condition: service_healthy
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: # دیتابیس و ذخیره‌سازی — فرانت‌اند راهی به این شبکه ندارد
- backend
ports:
- "5173:80"
volumes:
db_data:
minio_data:
chatbot_hf:
backups:
Binary file not shown.
-4
View File
@@ -1,4 +0,0 @@
# فقط Caddyfile و dist/ به کانتکست بیلد می‌روند — node_modules و سورس عظیم‌اند و لازم نیستند
*
!Caddyfile
!dist
-21
View File
@@ -1,21 +0,0 @@
{
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
admin off
}
:80 {
root * /srv
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"
}
+17 -14
View File
@@ -1,20 +1,23 @@
# ---------- فرانت‌اند فقط سرو می‌کند ----------
# بیلد روی هاست انجام می‌شود (npm install && npm run build) و خروجی dist/
# داخل ایمیج کپی می‌شود — شبکه کانتینر بیلد برای npm قابل‌اعتماد نیست.
# ---------- مرحله ساخت ----------
FROM node:22-alpine AS build
FROM caddy:2-alpine
WORKDIR /app
# نسخهٔ 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 package.json package-lock.json ./
RUN npm ci
COPY Caddyfile /etc/caddy/Caddyfile
COPY --chown=caddy:caddy dist /srv
COPY . .
# بدون root — فقط فایل‌های استاتیک سرو می‌شوند و TLS را Traefik تمام می‌کند
USER caddy
# آدرس API در زمان بیلد؛ پیش‌فرض خالی یعنی درخواست‌های نسبی (/api) که nginx آن‌ها را به بک‌اند پروکسی می‌کند
ARG VITE_API_URL=""
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# ---------- مرحله سرو ----------
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+5 -59
View File
@@ -1,62 +1,8 @@
# Magic Patterns - Vite Template
# Aramesh Construction Group — Frontend
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu)
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
## Getting Started
## Typography & Smart Bidi Text
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
## Recent UI/UX Improvements
* **Tiptap Editor Fixes**:
* Resolved the initialization bug where the editor body remained empty during edit mode.
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
* **Dynamic TOC & Layout**:
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
## CMS, SEO & Editor Features
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
* **Custom Tiptap WYSIWYG Editor:**
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
* **Full Jalali (Shamsi) Calendar Integration:**
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
## Development Setup
```bash
npm install
npm run dev
```
In development mode, Vite proxies `/api` and `/media` requests to Traefik (port 5173). Therefore, the Docker stack must be running.
The backend API URL is read from the `VITE_API_URL` environment variable in `src/config.ts`; an empty default implies same-origin requests.
## Build & Production
The build process runs on the host machine. The Docker container only serves the generated static files in the `dist/` directory using Caddy:
```bash
npm run build
```
> **Note:** The backend reads the `dist/` directory (mounted at `/srv/frontend` in Docker Compose) to inject SEO metadata into the `/blog` and `/projects` pages. After making any frontend changes, running `npm run build` and restarting the backend is sufficient.
## Key Modules & Architecture
* `src/api.tsx` — Handles site data loading and draft article merging for the admin view.
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
1. Run `npm install`
2. Run `npm run dev`
+3 -29
View File
@@ -1,39 +1,13 @@
<!doctype html>
<html lang="fa" dir="rtl">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/images/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>گروه ساختمانی آرامش</title>
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژه‌های ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایه‌گذاری ساخت‌وساز." />
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
</script>
<title>Aramesh Building Group Luxury Website</title>
</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>
+34
View File
@@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# مسیریابی SPA — همه مسیرها به index.html برمی‌گردند
location / {
try_files $uri $uri/ /index.html;
}
# پروکسی درخواست‌های API به سرویس بک‌اند
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# تصاویر: بدون کش طولانی — تا تغییرات سریع دیده شوند
location ~* ^/images/ {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# کش استاتیک
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
+4 -823
View File
@@ -9,21 +9,11 @@
"version": "0.0.1",
"dependencies": {
"@emotion/react": "^11.13.3",
"@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
"slugify": "^1.6.9",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
@@ -932,31 +922,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -1462,465 +1427,6 @@
"win32"
]
},
"node_modules/@tiptap/core": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz",
"integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz",
"integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz",
"integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz",
"integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
"integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz",
"integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz",
"integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
"integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz",
"integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.31.3"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz",
"integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
"integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.31.3"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz",
"integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz",
"integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz",
"integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.3.tgz",
"integrity": "sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz",
"integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz",
"integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz",
"integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz",
"integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz",
"integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz",
"integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz",
"integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
"integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
"integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz",
"integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz",
"integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/pm": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz",
"integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.11",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.42.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz",
"integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.31.3",
"@tiptap/extension-floating-menu": "^3.31.3"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
"integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/extension-blockquote": "3.31.3",
"@tiptap/extension-bold": "3.31.3",
"@tiptap/extension-bullet-list": "3.31.3",
"@tiptap/extension-code": "3.31.3",
"@tiptap/extension-code-block": "3.31.3",
"@tiptap/extension-document": "3.31.3",
"@tiptap/extension-dropcursor": "3.31.3",
"@tiptap/extension-gapcursor": "3.31.3",
"@tiptap/extension-hard-break": "3.31.3",
"@tiptap/extension-heading": "3.31.3",
"@tiptap/extension-horizontal-rule": "3.31.3",
"@tiptap/extension-italic": "3.31.3",
"@tiptap/extension-link": "3.31.3",
"@tiptap/extension-list": "3.31.3",
"@tiptap/extension-list-item": "3.31.3",
"@tiptap/extension-list-keymap": "3.31.3",
"@tiptap/extension-ordered-list": "3.31.3",
"@tiptap/extension-paragraph": "3.31.3",
"@tiptap/extension-strike": "3.31.3",
"@tiptap/extension-text": "3.31.3",
"@tiptap/extension-underline": "3.31.3",
"@tiptap/extensions": "3.31.3",
"@tiptap/pm": "3.31.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/static-renderer": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/static-renderer/-/static-renderer-3.31.3.tgz",
"integrity": "sha512-TzbN/aSTQBhGZL94aaEnfbwZw/gIiQd8/q3TucTYuzdbsrsZNfBMg7CCA9W9dOg4vlKxCTLRVas21ivsrUm7og==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2000,12 +1506,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2016,20 +1524,12 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
@@ -2037,12 +1537,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
@@ -2773,16 +2267,6 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.407",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
@@ -3094,15 +2578,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz",
"integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -3693,12 +3168,6 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3753,12 +3222,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3886,6 +3349,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3929,12 +3393,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4263,156 +3721,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
"integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.11",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz",
"integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.3",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4456,12 +3764,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-date-object": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/react-date-object/-/react-date-object-2.1.9.tgz",
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
"license": "MIT"
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -4475,52 +3777,12 @@
"react": "^18.3.1"
}
},
"node_modules/react-element-popper": {
"version": "2.1.7",
"resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz",
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-hook-form": {
"version": "7.88.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-multi-date-picker": {
"version": "4.5.2",
"resolved": "https://registry.npmmirror.com/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
"license": "MIT",
"dependencies": {
"react-date-object": "^2.1.8",
"react-element-popper": "^2.1.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4563,43 +3825,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-select": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4727,12 +3952,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -4812,15 +4031,6 @@
"node": ">=8"
}
},
"node_modules/slugify": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -5183,29 +4393,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -5273,12 +4460,6 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
-10
View File
@@ -11,21 +11,11 @@
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
"slugify": "^1.6.9",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
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.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

+28 -88
View File
@@ -5,7 +5,6 @@ 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';
@@ -16,35 +15,7 @@ import { Blog } from './pages/Blog';
import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage';
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} />
));
import { SiteProvider, useSiteData } from './api';
function ScrollToTop() {
const { pathname } = useLocation();
@@ -64,98 +35,67 @@ function AnimatedRoutes() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
<Routes location={location}>
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
{FA_ROUTES}
<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 />} />
<Route path="*" element={<Home />} />
{EN_ROUTES}
</Routes>
</motion.main>
</AnimatePresence>);
}
function AppChrome() {
const { pathname } = useLocation();
const { isAuthenticated } = useAdmin();
const data = useSite();
const { lang } = useLang();
const isAdminPage = pathname.startsWith('/admin');
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند
useEffect(() => {
if (isAdminPage) return;
const key = seoKeyForPath(pathname);
if (!key) return;
const meta = data.seo_meta?.find((s) => s.page_key === key);
const brand = data.brand.name;
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
applySeo({
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
description: meta?.description ?? '',
path: pathname,
lang,
});
}, [pathname, data, isAdminPage, lang]);
return (
<>
{!isAdminPage && <Header />}
<AnimatedRoutes />
{!isAdminPage && <Footer />}
{!isAdminPage && <FloatingContact />}
{!isAdminPage && <ChatWidget />}
{isAuthenticated && !isAdminPage && <AdminBar />}
</>
);
}
function AppShell() {
const { data, error, reload } = useSiteData();
const { t, dir, lang } = useLang();
// overflow-x-clip جای overflow-x-hidden: اسکرول‌افزار نمی‌سازد، پس position: sticky
// (فهرست مطالب مقاله) به صفحه می‌چسبد و با محتوا پایین نمی‌رود؛ برش افقی هم حفظ می‌شود.
return (
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden 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">{t('errorTitle')}</h1>
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</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 />
</>
<Header />
<AnimatedRoutes />
<Footer />
<FloatingContact />
</BrowserRouter>
) : null}
</div>
);
}
export function App() {
useEffect(() => {
document.documentElement.setAttribute('dir', 'rtl');
document.documentElement.setAttribute('lang', 'fa');
}, []);
return (
<SiteProvider>
<AdminProvider>
<BrowserRouter>
<LangProvider>
<AppShell />
</LangProvider>
</BrowserRouter>
</AdminProvider>
<AppShell />
</SiteProvider>
);
}
-225
View File
@@ -1,225 +0,0 @@
import { useState } from 'react'
import Swal from 'sweetalert2'
import { KeyRoundIcon, XIcon } from 'lucide-react'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
const STATUS_TEXT: Record<string, string> = {
idle: 'همگام‌سازی شد',
saving: 'در حال ذخیره…',
error: 'خطا در ذخیره',
}
const STATUS_COLOR: Record<string, string> = {
idle: 'bg-emerald-400',
saving: 'bg-copper animate-pulse',
error: 'bg-red-400',
}
const SWAL_BASE = {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
}
function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
if (next.length < 8) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دست‌کم ۸ نویسه باشد.', confirmButtonText: 'باشه' })
return
}
if (next !== confirm) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' })
return
}
setBusy(true)
try {
await adminApi.changePassword(current, next)
void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' })
onClose()
} catch (error) {
void Swal.fire({
...SWAL_BASE,
icon: 'error',
title: 'تغییر رمز ناموفق بود',
text: error instanceof Error ? error.message : 'دوباره تلاش کنید',
confirmButtonText: 'باشه',
})
} finally {
setBusy(false)
}
}
const field =
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none'
return (
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="تغییر رمز عبور">
<div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
<KeyRoundIcon className="h-4 w-4" />
تغییر رمز عبور
</h2>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="text-ivory/50 transition-colors hover:text-ivory"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-6 grid gap-4">
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder="رمز عبور فعلی"
aria-label="رمز عبور فعلی"
autoComplete="current-password"
className={field}
/>
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder="رمز جدید (دست‌کم ۸ نویسه)"
aria-label="رمز جدید"
autoComplete="new-password"
className={field}
/>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="تکرار رمز جدید"
aria-label="تکرار رمز جدید"
autoComplete="new-password"
className={field}
/>
</div>
<button
type="button"
onClick={() => void submit()}
disabled={busy || !current || !next}
className="mt-6 w-full bg-copper py-2.5 text-sm font-bold text-char transition-opacity disabled:opacity-50"
>
{busy ? 'در حال ذخیره…' : 'ذخیره رمز جدید'}
</button>
</div>
</div>
)
}
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
const [pwOpen, setPwOpen] = useState(false)
return (
<>
<div
dir="rtl"
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
>
<span className="flex items-center gap-2 text-xs font-bold text-copper">
<span className="flex h-2 w-2 rounded-full bg-copper" />
حالت مدیریت
</span>
<button
type="button"
role="switch"
aria-checked={isEditMode}
onClick={toggleEditMode}
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
>
<span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
}`}
>
<span
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
}`}
/>
</span>
{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]}
</span>
<button
type="button"
onClick={() => setPwOpen(true)}
className="flex items-center gap-1.5 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"
>
<KeyRoundIcon className="h-3.5 w-3.5" />
رمز
</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}
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"
>
خروج
</button>
</div>
{pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
</>
)
}
-227
View File
@@ -1,227 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
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
isEditMode: boolean
toggleEditMode: () => void
login: (username: string, password: string) => Promise<void>
logout: () => void
saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
runSave: (action: () => Promise<void>) => Promise<boolean>
/** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */
editLang: EditLang
toggleEditLang: () => void
}
const AdminContext = createContext<AdminContextValue | null>(null)
export function useAdmin(): AdminContextValue {
const ctx = useContext(AdminContext)
if (!ctx) throw new Error('useAdmin باید داخل AdminProvider استفاده شود')
return ctx
}
function showSaveError(error: unknown): void {
const message =
error instanceof HttpError ? error.message : 'ارتباط با سرور برقرار نشد — اتصال اینترنت را بررسی کنید'
Swal.fire({
icon: 'error',
title: 'ذخیره نشد',
text: message,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'باشه',
timer: 4000,
timerProgressBar: true,
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
})
}
export function AdminProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
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()
const toggleEditMode = useCallback(() => {
setIsEditMode((v) => {
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
return !v
})
}, [])
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)
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند
reload()
}, [reload])
const logout = useCallback(() => {
setToken(null)
setIsAuthenticated(false)
setIsEditMode(false)
sessionStorage.removeItem('aramesh_edit_mode')
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند
reload()
}, [reload])
const runSave = useCallback(
async (action: () => Promise<void>): Promise<boolean> => {
setSaveStatus('saving')
try {
await action()
setSaveStatus('idle')
return true
} catch (error) {
setSaveStatus('error')
showSaveError(error)
if (error instanceof HttpError && error.status === 401) {
logout()
reload()
}
return false
}
},
[logout, reload],
)
// در حالت ویرایش، کلیک روی لینک‌ها مسیریابی نمی‌کند تا ویرایش متن ممکن باشد
useEffect(() => {
if (!isEditMode) return
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement | null)?.closest('a')
// لینک‌های خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار می‌کنند
if (anchor && !anchor.dataset.adminLink) e.preventDefault()
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
}, [isEditMode])
// در حین ذخیره‌سازی، از بسته‌شدن تصادفی صفحه جلوگیری می‌شود
useEffect(() => {
if (saveStatus !== 'saving') return
const guard = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
window.addEventListener('beforeunload', guard)
return () => window.removeEventListener('beforeunload', guard)
}, [saveStatus])
const value = useMemo<AdminContextValue>(
() => ({
isAuthenticated,
isEditMode,
toggleEditMode,
login,
logout,
saveStatus,
runSave,
editLang,
toggleEditLang,
}),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
)
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
}
/**
* پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت
* همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند.
*
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت می‌شوند؛ چون
* برخی mutatorهای کامپوننت‌ها نام فیلد را جداگانه می‌نویسند، مقدار قبلی فیلدهای
* فارسی اسنپ‌شات می‌شود و پس از اجرای mutator بازگردانده می‌شود تا متن انگلیسی
* هرگز جای متن فارسی در درخت خام ننشیند.
*/
export function useFieldSaver() {
const { runSave, editLang } = useAdmin()
const { updateSite } = useSiteData()
return useCallback(
(
resource: string,
id: number,
changes: Record<string, unknown>,
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((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, editLang],
)
}
/**
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
* پسوند _en می‌گیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره می‌شود.
*/
export function useEditField() {
const { editLang } = useAdmin()
return useCallback(
(resource: string, field: string) =>
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
[editLang],
)
}
-85
View File
@@ -1,85 +0,0 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useAdmin } from './AdminContext'
/** صفحه ورود ادمین — مسیر پنهان /admin/login */
export function AdminLoginPage() {
const { isAuthenticated, login } = useAdmin()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
if (isAuthenticated) return <Navigate to="/" replace />
const submit = async (e: FormEvent) => {
e.preventDefault()
if (submitting) return
setError('')
setSubmitting(true)
try {
await login(username.trim(), password)
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
} finally {
setSubmitting(false)
}
}
return (
<section className="flex min-h-screen flex-col items-center justify-center bg-ink px-6" dir="rtl">
<div className="w-full max-w-sm border border-ivory/10 bg-char p-10">
<h1 className="text-xl font-bold text-ivory">ورود مدیریت</h1>
<p className="mt-3 text-sm font-light leading-7 text-ivory/55">
برای فعال‌سازی حالت ویرایش زنده سایت وارد شوید.
</p>
<form onSubmit={submit} className="mt-8 grid gap-5" noValidate>
<div>
<label htmlFor="admin-username" className="block text-sm font-bold text-ivory/90">
نام کاربری
</label>
<input
id="admin-username"
type="text"
dir="ltr"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="admin-password" className="block text-sm font-bold text-ivory/90">
رمز عبور
</label>
<input
id="admin-password"
type="password"
dir="ltr"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
/>
</div>
{error && (
<p className="border-r-2 border-red-400/70 pr-3 text-xs font-light leading-6 text-red-300">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="mt-2 bg-copper px-8 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-[1.02] disabled:opacity-50"
>
{submitting ? 'در حال ورود…' : 'ورود'}
</button>
</form>
</div>
</section>
)
}
-662
View File
@@ -1,662 +0,0 @@
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, PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
import { formatReadingTime } from '../utils/readingTime'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
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; body_en: ArticleBodyDoc }
/**
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت می‌شوند:
* عنوان (مبنای Alt پویا)، دسته‌بندی و متادیتای تصویر شاخص.
* `category` در فرم یک شیء تک‌مقداری {value,label} یا null است
* که هنگام ذخیره به رشته (ستون category در بک‌اند) نگاشت می‌شود.
*/
type ArticleMetaForm = {
title: string
category: CategoryOption | null
image_alt: string
image_title: string
}
/** مقدار رشته‌ای ذخیره‌شده → گزینه react-select (یا null وقتی خالی است) */
function toCategoryOption(value: string): CategoryOption | null {
const category = (value ?? '').trim()
return category ? { label: category, value: category } : null
}
/** گزینه react-select → مقدار رشته‌ای ذخیره‌سازی در بک‌اند */
function categoryValue(option: CategoryOption | null): string {
return option?.value.trim() ?? ''
}
/** داده‌های قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
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: 'در حال ذخیره…',
error: 'خطا در ذخیره',
}
/**
* صفحه تمام‌عرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
*/
export function ArticleWritePage() {
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data, updateSite } = useSiteData()
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
const [fetched, setFetched] = useState<Draft | null>(null)
// مقاله تازه همیشه پیش‌نویس شروع می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود
const [draft, setDraft] = useState<Draft>({
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: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
read_en: '',
excerpt: '',
excerpt_en: '',
body: EMPTY_DOC,
body_en: EMPTY_DOC,
status: 'draft',
seo_keyphrase: '',
})
/**
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
* هر تغییر، هم فرم و هم draft (مبنای ذخیره‌سازی خودکار) را به‌روز می‌کند.
*/
const { control, watch, reset } = useForm<ArticleMetaForm>({
defaultValues: {
title: '',
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
image_alt: '',
image_title: '',
},
mode: 'onChange',
})
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
const watchedTitle = watch('title')
const { field: imageAltField } = useController({ name: 'image_alt', control })
const { field: imageTitleField } = useController({ name: 'image_title', control })
const [createdId, setCreatedId] = useState<number | null>(null)
/**
* شناسه محتوای بارگذاری‌شده — با رسیدن مقاله از سرور عوض می‌شود تا
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
*/
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft)
draftRef.current = draft
const createdRef = useRef(createdId)
createdRef.current = createdId
const pendingRef = useRef<Record<string, unknown> | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیش‌نویس‌ها) مستقیم از انتهای مدیریتی خوانده می‌شود
useEffect(() => {
if (!id || existing) return
let cancelled = false
adminApi
.get<Draft>('articles', Number(id))
.then((a) => {
if (!cancelled) setFetched(a)
})
.catch(() => {
/* مقاله وجود ندارد — فرم «مقاله جدید» می‌ماند */
})
return () => {
cancelled = true
}
}, [id, existing])
const article = existing ?? fetched
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => {
if (!article || createdRef.current) return
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({
...(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)
// بدنه ویرایشگر با محتوای بارگذاری‌شده همگام می‌شود
setLoadedContentKey(article.id)
// فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود
reset({
title: article.title ?? '',
category: toCategoryOption(article.category ?? ''),
image_alt: article.image_alt ?? '',
image_title: article.image_title ?? '',
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [article?.id])
const doSave = async (changes: Record<string, unknown>) => {
if (createdRef.current) {
await adminApi.patch('articles', createdRef.current, changes)
updateSite((d) => {
const a = d.articles.find((x) => x.id === createdRef.current)
if (a) Object.assign(a, changes)
})
} else {
// اولین ذخیره = ساخت پیش‌نویس: مقاله ساخته می‌شود اما تا «انتشار» در سایت عمومی دیده نمی‌شود
const payload = { ...draftRef.current, ...changes }
if (!payload.title) payload.title = 'بدون عنوان'
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
setCreatedId(created.id)
updateSite((d) => {
d.articles.unshift(created as never)
})
navigate(`/admin/write/${created.id}`, { replace: true })
}
}
const flush = useCallback(async () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
const changes = pendingRef.current
if (!changes) return
pendingRef.current = null
await doSave(changes)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const queueSave = (changes: Record<string, unknown>) => {
pendingRef.current = { ...(pendingRef.current ?? {}), ...changes }
setDraft((d) => ({ ...d, ...changes }) as Draft)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => void flush(), 800)
}
// ذخیره تغییرات در انتظار هنگام ترک صفحه — فقط برای مقاله ساخته‌شده
// (create در unmount ممنوع: با draft قدیمی، مقاله سرگردانی می‌سازد)
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
const changes = pendingRef.current
if (!changes || !createdRef.current) return
void adminApi.patch('articles', createdRef.current, changes).catch(() => {
/* صفحه در حال بستن است */
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const forceSave = () => void runSave(flush)
// محافظ مسیر — پس از همه هوک‌ها تا ترتیب فراخوانی هوک‌ها همیشه یکسان بماند
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
const remove = async () => {
if (!createdId) return
const result = await Swal.fire({
icon: 'warning',
title: 'حذف مقاله',
text: 'این مقاله برای همیشه حذف شود؟',
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
})
if (!result.isConfirmed) return
setDeleting(true)
await runSave(async () => {
await adminApi.remove('articles', createdId)
navigate('/blog')
})
setDeleting(false)
}
const textField = (name: keyof Draft) => ({
value: String(draft[name] ?? ''),
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
queueSave({ [name]: e.target.value }),
})
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]
.filter(Boolean)
.join(' - ')
return (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
{/* نوار بالا */}
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
<Link
to="/blog"
data-admin-link
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
<ArrowRightIcon className="h-4 w-4" />
بازگشت به وبلاگ
</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]}
</span>
<button
type="button"
onClick={forceSave}
className="border border-copper/50 px-5 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
ذخیره
</button>
<span
className={`border px-3 py-2 text-xs font-light ${
draft.status === 'draft'
? 'border-amber-400/50 text-amber-300'
: 'border-emerald-400/50 text-emerald-300'
}`}
>
{draft.status === 'draft' ? 'پیش‌نویس' : 'منتشر شده'}
</span>
{createdId && (
<>
<button
type="button"
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
draft.status === 'draft'
? 'bg-copper text-char hover:scale-105'
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button>
<Link
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"
>
<EyeIcon className="h-3.5 w-3.5" />
دیدن در سایت
</Link>
<button
type="button"
onClick={remove}
disabled={deleting}
aria-label="حذف مقاله"
className="flex h-8 w-8 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
<p className="text-xs font-light text-copper">
{!createdId
? 'نوشتن مقاله جدید — به‌صورت پیش‌نویس ذخیره می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود'
: draft.status === 'draft'
? 'پیش‌نویس — تا انتشار در سایت عمومی دیده نمی‌شود؛ تغییرها ذخیره می‌شوند'
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p>
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره می‌شود */}
<Controller
name="title"
control={control}
render={({ field }) => (
<input
type="text"
{...field}
onChange={(event) => {
field.onChange(event.target.value)
queueSave({ [suf('title')]: event.target.value })
}}
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"
/>
)}
/>
<div className="mt-4 h-px w-28 bg-copper" />
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصل‌اند) */}
<div className="mt-8">
<ImageField
label="تصویر شاخص مقاله"
value={draft.image}
alt={imageAltField.value}
altFallback={imageAltFallback}
title={imageTitleField.value}
onSave={async (url) => {
queueSave({ image: url })
return true
}}
onSaveAlt={async (alt) => {
imageAltField.onChange(alt)
queueSave({ image_alt: alt })
return true
}}
onSaveTitle={async (imageTitle) => {
imageTitleField.onChange(imageTitle)
queueSave({ image_title: imageTitle })
return true
}}
/>
</div>
{/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */}
<div className="mt-8">
<ArticleEditor
contentKey={loadedContentKey}
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
const minutes = formatReadingTime(json)
if (editLang === 'en') {
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
queueSave({ body: json, read: `${minutes} دقیقه` })
}
}}
/>
</div>
{/* توضیحات متای سئو */}
<section className="mt-8 border border-ivory/15 bg-char p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
<span
className={`text-[11px] font-light ${
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
}`}
>
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
</span>
</div>
<p className="mt-1 text-xs font-light text-ivory/50">
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده می‌شود (۱۵۰ تا ۱۶۰ نویسه).
</p>
<textarea
id="meta-description"
rows={3}
value={draft.meta_description ?? ''}
onChange={(e) => queueSave({ meta_description: e.target.value })}
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</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={{
title: draft.title,
slug: draft.slug,
excerpt: draft.meta_description || draft.excerpt,
body: draft.body,
keyphrase: draft.seo_keyphrase,
}}
/>
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دسته‌بندی و هشتگ‌ها</h2>
<div className="mt-5 grid gap-5">
<div>
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label>
<input
id="seo-keyphrase"
type="text"
value={draft.seo_keyphrase}
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
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>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
rows={2}
{...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>
<div>
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
<input
id="article-author"
type="text"
{...textField('author')}
placeholder="نام و نام خانوادگی نویسنده"
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div>
<label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label>
<div className="mt-2">
{/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
<Controller
name="category"
control={control}
render={({ field }) => (
<CategorySelect
value={field.value}
options={ARTICLE_CATEGORIES}
onChange={(option) => {
field.onChange(option)
queueSave({ category: categoryValue(option) })
}}
/>
)}
/>
</div>
</div>
<div>
<label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label>
<div className="mt-2">
<LuxeTagsSelect
value={normalizeTags(draft.tags)}
onChange={(tags) => queueSave({ tags })}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div>
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
<div className="mt-2">
<JalaliDatePicker
id="article-date"
value={draft.date ?? ''}
onChange={(isoDate) => queueSave({ date: isoDate })}
/>
</div>
</div>
<div>
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
<input
type="text"
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>
<label className="text-xs font-light text-ivory/50">نشانی (slug)</label>
<input
type="text"
dir="ltr"
{...textField('slug')}
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>
</div>
</div>
</section>
</main>
</div>
)
}
-290
View File
@@ -1,290 +0,0 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { Link } from 'react-router-dom'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import type { SiteData } from '../api'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
import { ENTITIES } from './entities'
import { EntityDrawer } from './EntityDrawer'
type ItemWithId = Record<string, unknown> & { id: number }
function useEntityList(resource: string): ItemWithId[] {
const { data } = useSiteData()
const config = ENTITIES[resource]
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
}
function PillButton({
onClick,
label,
children,
danger = false,
}: {
onClick: () => void
label: string
children: ReactNode
danger?: boolean
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClick()
}}
className={`flex h-7 w-7 items-center justify-center border backdrop-blur-sm transition-colors duration-150 ${
danger
? 'border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20'
: 'border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char'
}`}
>
{children}
</button>
)
}
/**
* بسته‌بندی آیتم‌های مجموعه‌ها — فقط در حالت ویرایش ادمین، دکمه‌های شناور
* (ویرایش / بالا / پایین / حذف) روی آیتم نشان می‌دهد؛ در حالت عادی خروجی یکسان است.
*/
export function EditableItem({
resource,
item,
children,
className = '',
editHref,
}: {
resource: string
item: ItemWithId
children: ReactNode
className?: string
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */
editHref?: string
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const { reload } = useSiteData()
const config = ENTITIES[resource]
const list = useEntityList(resource)
const [drawerOpen, setDrawerOpen] = useState(false)
if (!config) return <>{children}</>
if (!isAuthenticated || !isEditMode) return <>{children}</>
const index = list.findIndex((x) => x.id === item.id)
const move = (delta: number) => {
const target = index + delta
if (target < 0 || target >= list.length) return
const ids = list.map((x) => x.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
void runSave(async () => {
await adminApi.reorder(config.resource, ids)
reload()
})
}
const remove = async () => {
const result = await Swal.fire({
icon: 'warning',
title: 'حذف رکورد',
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
})
if (!result.isConfirmed) return
void runSave(async () => {
await adminApi.remove(config.resource, item.id)
reload()
})
}
return (
<div className={`relative group/editable ${className}`}>
<div className="absolute left-2 top-2 z-30 flex flex-col gap-1.5 opacity-0 transition-opacity duration-150 group-hover/editable:opacity-100">
{editHref ? (
<Link
to={editHref}
data-admin-link
title="ویرایش"
aria-label="ویرایش"
className="flex h-7 w-7 items-center justify-center border border-copper/50 bg-ink/90 text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char"
>
<PencilIcon className="h-3.5 w-3.5" />
</Link>
) : (
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
)}
{index > 0 && (
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
<ArrowUpIcon className="h-3.5 w-3.5" />
</PillButton>
)}
{index < list.length - 1 && (
<PillButton onClick={() => move(1)} label="انتقال به بعد">
<ArrowDownIcon className="h-3.5 w-3.5" />
</PillButton>
)}
<PillButton onClick={remove} label="حذف" danger>
<Trash2Icon className="h-3.5 w-3.5" />
</PillButton>
</div>
{children}
{drawerOpen && (
<EntityDrawer config={config} item={item} deletable onClose={() => setDrawerOpen(false)} />
)}
</div>
)
}
/** دکمه افزودن آیتم جدید به یک مجموعه — فقط در حالت ویرایش */
export function AddItemButton({
resource,
label,
variant = 'cell',
}: {
resource: string
label?: string
variant?: 'cell' | 'button'
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const { updateSite } = useSiteData()
const config = ENTITIES[resource]
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null
const create = () => {
void runSave(async () => {
const created = await adminApi.create<ItemWithId>(config.resource, config.defaults)
// درج خوش‌بینانه — reload کل درخت را بازسازی می‌کند و کشوی تازه‌ساخته‌شده می‌میرد
updateSite((d) => {
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(created)
})
setNewItem(created)
})
}
const drawer = newItem ? (
<EntityDrawer config={config} item={newItem} deletable onClose={() => setNewItem(null)} />
) : null
if (variant === 'button') {
return (
<>
<button
type="button"
onClick={create}
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
>
<PlusIcon className="h-4 w-4" />
{label ?? `افزودن ${config.title}`}
</button>
{drawer}
</>
)
}
return (
<>
<button
type="button"
onClick={create}
className="flex min-h-40 w-full flex-col items-center justify-center gap-2 border border-dashed border-copper/40 bg-copper/5 text-copper transition-colors duration-200 hover:bg-copper/10"
>
<PlusIcon className="h-6 w-6" />
<span className="text-sm font-light">افزودن {config.title}</span>
</button>
{drawer}
</>
)
}
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را می‌سازد. */
export function SingletonPill({
resource,
label,
className = '',
select,
createDefaults,
}: {
resource: string
label: string
className?: string
/** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const config = ENTITIES[resource]
const { data, updateSite } = useSiteData()
const [drawerOpen, setDrawerOpen] = useState(false)
const [created, setCreated] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null
const item =
created ??
(select
? select(data!)
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined))
if (!item && !createDefaults) return null
const create = () => {
const defaults =
typeof createDefaults === 'function' ? createDefaults() : (createDefaults ?? {})
void runSave(async () => {
const rec = await adminApi.create<ItemWithId>(config.resource, defaults)
// درج خوش‌بینانه در درخت داده — دفعه بعد از داده انتخاب می‌شود
updateSite((d) => {
const list = d[config.dataKey as keyof NonNullable<typeof d>] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(rec)
})
setCreated(rec)
setDrawerOpen(true)
})
}
return (
<>
<span className={className}>
{item ? (
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
) : (
<PillButton onClick={create} label={`ساخت — ${label}`}>
<PlusIcon className="h-3.5 w-3.5" />
</PillButton>
)}
</span>
{drawerOpen && item && (
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
)}
</>
)
}
-494
View File
@@ -1,494 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
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'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = {
config: EntityConfig
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
item: Record<string, unknown> & { id: number }
onClose: () => void
/** فقط برای مجموعه‌ها: حذف آیتم */
deletable?: boolean
}
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
const save = useFieldSaver()
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()
}
const saveField = (name: string, value: unknown) =>
save(config.resource, item.id, { [name]: value }, (d) => {
const target = d[config.dataKey as keyof typeof d] as unknown
const node =
config.kind === 'singleton'
? (target as Record<string, unknown>)
: (target as { id: number }[]).find((x) => x.id === item.id)
if (node) (node as Record<string, unknown>)[name] = value
})
const remove = async () => {
const result = await Swal.fire({
icon: 'warning',
title: 'حذف رکورد',
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
})
if (!result.isConfirmed) return
setDeleting(true)
await runSave(async () => {
await adminApi.remove(config.resource, item.id)
reload()
onClose()
})
setDeleting(false)
}
return (
<AnimatePresence>
<motion.div
key="backdrop"
className="fixed inset-0 z-[95] bg-char/70 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={closeWithFlush}
aria-hidden="true"
/>
<motion.aside
key="drawer"
dir="rtl"
role="dialog"
aria-label={`ویرایش ${config.title}`}
className={`fixed inset-y-0 left-0 z-[96] flex w-full flex-col border-e border-ivory/10 bg-ink shadow-2xl ${
config.resource === 'articles' ? 'sm:max-w-3xl' : 'sm:max-w-md'
}`}
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
>
<header className="flex items-center justify-between border-b border-ivory/10 px-6 py-4">
<div>
<p className="text-xs font-light text-copper">
{config.kind === 'singleton' ? 'تنظیمات بخش' : 'ویرایش رکورد'}
</p>
<h2 className="mt-1 text-lg font-bold text-ivory">{config.title}</h2>
</div>
<button
type="button"
onClick={closeWithFlush}
aria-label="بستن"
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6">
{fields.map((field) => {
const isImage = field.type === 'image'
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
const altKey = isImage
? field.name === 'src' && 'alt' in item
? 'alt'
: `${field.name}_alt`
: ''
const imageTitleKey = isImage ? `${field.name}_title` : ''
// پیشنهاد Alt از عنوان رکورد ساخته می‌شود (هرگز از URL)
const imageAltFallback = [
String(item[config.titleKey] ?? '').trim(),
field.altFallbackSuffix,
]
.filter(Boolean)
.join(' - ')
return (
<FieldControl
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}
onSave={(value) => saveField(field.name, value)}
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
onSaveImageTitle={
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
flushRef.current = fn
}
: undefined
}
/>
)
})}
</div>
</div>
{deletable && (
<footer className="border-t border-ivory/10 px-6 py-4">
<button
type="button"
onClick={remove}
disabled={deleting}
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2Icon className="h-3.5 w-3.5" />
حذف این رکورد
</button>
</footer>
)}
</motion.aside>
</AnimatePresence>
)
}
function FieldControl({
field,
value,
altValue,
altFallback,
placeholder,
imageTitleValue,
onSave,
onSaveAlt,
onSaveImageTitle,
registerFlush,
}: {
field: FieldConfig
value: unknown
/** مقدار Alt تصویر — فقط برای فیلدهای image */
altValue?: string
altFallback?: string
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown
/** مقدار Title تصویر — فقط برای فیلدهای image */
imageTitleValue?: string
onSave: (value: unknown) => Promise<boolean>
onSaveAlt?: (alt: string) => Promise<boolean>
onSaveImageTitle?: (title: string) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [text, setText] = useState<string>(String(value ?? ''))
// همگام‌سازی مقدار محلی با داده‌ای که از بیرون می‌آید (بارگذاری/به‌روزرسانی خوش‌بینانه)
useEffect(() => {
setText(String(value ?? ''))
}, [value])
const commitText = async () => {
const next = field.type === 'number' ? Number(text) || 0 : text
if (String(value ?? '') === String(next)) return
await onSave(next)
}
if (field.type === 'image') {
return (
<ImageField
label={field.label}
value={String(value ?? '')}
alt={altValue}
altFallback={altFallback}
title={imageTitleValue}
onSave={(url) => onSave(url)}
onSaveAlt={onSaveAlt}
onSaveTitle={onSaveImageTitle}
/>
)
}
if (field.type === 'select') {
// مقدار نمایشی از وضعیت محلی می‌آید تا افزودن/حذف گزینه فوراً دیده شود
const selectProps = {
value: text,
options: field.options ?? [],
onChange: (next: string) => {
setText(next)
void onSave(next)
},
}
return (
<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>}
<div className="mt-2">
{field.creatable ? (
<LuxeCreatableSelect {...selectProps} />
) : (
<LuxeSelect {...selectProps} />
)}
</div>
</div>
)
}
if (field.type === 'date') {
return (
<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>}
<div className="mt-2">
<JalaliDatePicker
value={String(value ?? '')}
onChange={(isoDate) => {
setText(isoDate)
void onSave(isoDate)
}}
/>
</div>
</div>
)
}
if (field.type === 'wysiwyg') {
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} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} />
)
}
if (field.type === 'textarea') {
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
<textarea
dir="auto"
rows={4}
placeholder={placeholder ? String(placeholder) : undefined}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
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>
)
}
return (
<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>}
<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}
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>
)
}
/** فیلد هشتگ‌ها — react-select قابل ساخت (Creatable) با ذخیره فوری */
function TagsField({
field,
value,
onSave,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
}) {
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
const update = (next: string[]) => {
setTags(next)
void onSave(next)
}
return (
<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>}
<div className="mt-2">
<LuxeTagsSelect value={tags} onChange={update} />
</div>
</div>
)
}
/** ویرایش آرایه خطی (پاراگراف‌ها، گالری، نکات) — هر تغییر ذخیره می‌شود */
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[]) : [])
const update = (next: string[]) => {
setLines(next)
void onSave(next)
}
return (
<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">
<textarea
dir="auto"
rows={line.length > 60 ? 3 : 1}
value={line}
onChange={(e) => {
const next = [...lines]
next[i] = e.target.value
setLines(next)
}}
onBlur={() => {
if (line !== (value as string[])[i]) update(lines)
}}
className="flex-1 resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
<button
type="button"
aria-label="حذف خط"
onClick={() => update(lines.filter((_, j) => j !== i))}
className="mt-1 text-ivory/40 transition-colors hover:text-red-300"
>
<XIcon className="h-4 w-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => update([...lines, ''])}
className="flex w-fit items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1.5 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
>
<PlusIcon className="h-3 w-3" />
افزودن خط
</button>
</div>
</div>
)
}
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
function WysiwygField({
field,
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 یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
() => toArticleBodyDoc(value),
)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null)
const flush = () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current) {
const next = pending.current
pending.current = null
void onSave(next)
}
}
// ثبت آخرین flush در کشو + ذخیره تغییرات در انتظار هنگام unmount
useEffect(() => {
registerFlush?.(() => flush())
return () => {
if (timer.current) clearTimeout(timer.current)
if (pending.current) {
const next = pending.current
pending.current = null
void onSave(next)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
})
const handleChange = (json: Record<string, unknown>) => {
setDoc(json)
pending.current = json
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(flush, 800)
}
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>
<div className="mt-2">
<ArticleEditor doc={doc} onChange={handleChange} />
</div>
</div>
)
}
-122
View File
@@ -1,122 +0,0 @@
// لایه تماس ادمین — همه درخواست‌ها با توکن Bearer و مدیریت خطای یکسان
import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token'
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string | null): void => {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
}
}
async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const isFormData = options.body instanceof FormData
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: {
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.body && !isFormData ? { 'Content-Type': 'application/json' } : {}),
},
})
if (res.status === 401) {
setToken(null)
throw new HttpError(401, 'نشست شما منقضی شده — دوباره وارد شوید')
}
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
const detail =
typeof data?.detail === 'string'
? data.detail
: res.status === 409
? 'رکوردی با این مقدار تکراری وجود دارد'
: 'خطا در ذخیره‌سازی — دوباره تلاش کنید'
throw new HttpError(res.status, detail)
}
return res.status === 204 ? (null as T) : ((await res.json()) as T)
}
export const adminApi = {
login: async (username: string, password: string): Promise<string> => {
const res = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
throw new HttpError(
res.status,
typeof data?.detail === 'string' ? data.detail : 'ورود ناموفق بود',
)
}
const data = (await res.json()) as { access_token: string }
setToken(data.access_token)
return data.access_token
},
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
list: <T = unknown>(resource: string) => authFetch<T>(`/api/admin/${resource}`),
get: <T = unknown>(resource: string, id: number) =>
authFetch<T>(`/api/admin/${resource}/${id}`),
create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
remove: (resource: string, id: number) =>
authFetch<null>(`/api/admin/${resource}/${id}`, { method: 'DELETE' }),
reorder: <T = unknown>(resource: string, ids: number[]) =>
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
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)
},
}
-256
View File
@@ -1,256 +0,0 @@
import type { SiteData } from '../api'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
export type FieldConfig = {
name: string
label: string
type: FieldType
hint?: string
/** فقط برای type === 'select' — گزینه‌های ثابت react-select */
options?: string[]
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
creatable?: boolean
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
ui?: 'lines' | 'tags'
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
altFallbackSuffix?: string
}
export type EntityConfig = {
resource: string
dataKey: string
title: string
kind: 'collection' | 'singleton'
titleKey: string
fields: FieldConfig[]
defaults: Record<string, unknown>
}
const F = (
name: string,
label: string,
type: FieldType = 'text',
hint?: string,
sharedOrExtra?: boolean | Partial<FieldConfig>,
): FieldConfig => ({
name,
label,
type,
hint,
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
? sharedOrExtra
: { shared: !!sharedOrExtra }),
})
/** دسته‌بندی‌های استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده می‌شوند */
export const ARTICLE_CATEGORIES = [
'معماری و ساخت',
'سرمایه‌گذاری ملکی',
'طراحی داخلی',
'اخبار و اطلاعیه‌ها',
'یادداشت مدیریت',
]
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', true)],
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
},
projects: {
resource: 'projects', dataKey: 'projects', title: 'پروژه‌ها', kind: 'collection',
titleKey: 'title',
fields: [
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'),
],
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
},
articles: {
resource: 'articles', dataKey: 'articles', title: 'مقالات', kind: 'collection',
titleKey: 'title',
fields: [
F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'),
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)', 'text', undefined, true),
],
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: '',
seo_keyphrase: '',
body: { type: 'doc', content: [{ type: 'paragraph' }] }
},
},
testimonials: {
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
titleKey: 'name',
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
defaults: { name: 'نظر جدید', role: '', quote: '' },
},
faqs: {
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
titleKey: 'question',
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
defaults: { question: 'سوال جدید', answer: '' },
},
team: {
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
titleKey: 'name',
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
defaults: { name: 'عضو جدید', role: '', bio: '' },
},
timeline: {
resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection',
titleKey: 'title',
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', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
},
why_us: {
resource: 'why-us', dataKey: 'why_us', title: 'چرا آرامش', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
defaults: { title: 'مورد جدید', desc: '' },
},
investment_models: {
resource: 'investment-models', dataKey: 'investment_models', title: 'مدل‌های سرمایه‌گذاری', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('points', 'نکات (هر خط یک مورد)', 'list')],
defaults: { title: 'مدل جدید', desc: '', points: [] },
},
process_steps: {
resource: 'process-steps', dataKey: 'process_steps', title: 'مراحل فرآیند کار', kind: 'collection',
titleKey: 'title',
fields: [F('step', 'شماره مرحله'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
defaults: { step: '۰۱', title: 'مرحله جدید', desc: '' },
},
offices: {
resource: 'offices', dataKey: 'offices', title: 'دفاتر', kind: 'collection',
titleKey: 'city',
fields: [F('city', 'شهر'), F('address', 'نشانی', 'textarea'), F('hours', 'ساعات کاری')],
defaults: { city: 'شهر جدید', address: '', hours: '' },
},
gallery: {
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
titleKey: '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)', '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', true),
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
],
defaults: { page_key: '', title: '', description: '' },
},
brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name',
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', 'لینک دکمه اصلی', '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('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', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
defaults: {},
},
contact_section: {
resource: 'contact-section', dataKey: 'contact_section', title: 'فرم تماس', kind: 'singleton',
titleKey: 'title',
fields: [
F('label', 'برچسب بالا'), F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'),
F('address_label', 'برچسب نشانی'), F('phone_label', 'برچسب تلفن'), F('email_label', 'برچسب ایمیل'),
F('name_label', 'برچسب نام'), F('name_placeholder', 'جای‌نگهدار نام'),
F('phone_field_label', 'برچسب تلفن (فرم)'), F('phone_placeholder', 'جای‌نگهدار تلفن'),
F('note_label', 'برچسب توضیحات'), F('note_optional_label', 'برچسب اختیاری'), F('note_placeholder', 'جای‌نگهدار توضیحات'),
F('submit_label', 'متن دکمه ارسال'), F('sending_label', 'متن در حال ارسال'),
F('success_title', 'عنوان پیام موفقیت'), F('success_text', 'متن پیام موفقیت', 'textarea'), F('success_button_label', 'دکمه پیام موفقیت'),
],
defaults: {},
},
newsletter: {
resource: 'newsletter', dataKey: 'newsletter', title: 'خبرنامه', kind: 'singleton',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('placeholder', 'جای‌نگهدار ایمیل'), F('button_label', 'متن دکمه'), F('sending_label', 'متن در حال ارسال'), F('success_text', 'پیام موفقیت')],
defaults: {},
},
footer: {
resource: 'footer', dataKey: 'footer', title: 'فوتر', kind: 'singleton',
titleKey: 'pages_heading',
fields: [F('about_text', 'متن معرفی', 'textarea'), F('pages_heading', 'تیتر ستون صفحات'), F('services_heading', 'تیتر ستون خدمات'), F('contact_heading', 'تیتر ستون تماس'), F('copyright', 'متن کپی‌رایت'), F('tagline', 'شعار پایین')],
defaults: {},
},
}
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
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
}
-17
View File
@@ -1,17 +0,0 @@
// لایهٔ نازک تحلیل رفتار (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 {
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
}
}
+12 -263
View File
@@ -2,15 +2,11 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
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
@@ -18,186 +14,26 @@ export type BrandInfo = {
phone_href: string
email: string
address: string
address_en?: 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 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 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; 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; title_en?: string; lead: string; lead_en?: string; image: 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 }
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 {
brand: BrandInfo
nav: NavItem[]
hero: HeroContent
story: StoryContent
section_headings: SectionHeadingContent[]
page_heroes: PageHeroContent[]
services: Service[]
projects: Project[]
articles: Article[]
@@ -208,31 +44,15 @@ export interface SiteData {
stats: StatItem[]
why_us: WhyUsItem[]
before_after: BeforeAfterInfo
investment_models: InvestmentModelItem[]
process_steps: ProcessStepItem[]
offices: OfficeItem[]
gallery: GalleryImageItem[]
status_labels: StatusLabelItem[]
contact_section: ContactSectionContent
newsletter: NewsletterContent
footer: FooterContent
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
}
interface SiteContextValue {
data: SiteData | null
error: boolean
reload: () => void
/** به‌روزرسانی خوش‌بینانه درخت داده — پس از موفقیت PATCH استفاده می‌شود */
updateSite: (mutate: (data: SiteData) => void) => void
}
const SiteContext = createContext<SiteContextValue>({
data: null,
error: false,
reload: () => {},
updateSite: () => {},
})
const SiteContext = createContext<SiteContextValue>({ data: null, error: false, reload: () => {} })
export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null)
@@ -246,27 +66,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<SiteData>
})
.then(async (json) => {
if (cancelled) return
// برای ادمین واردشده، پیش‌نویس مقاله‌ها از انتهای مدیریتی خوانده و ادغام می‌شوند —
// بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند
if (getToken()) {
try {
const res = await fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (res.ok) {
const all = (await res.json()) as Article[]
const published = new Set(json.articles.map((a) => a.id))
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
json.articles = [...json.articles, ...drafts]
}
} catch {
/* بدون پیش‌نویس‌ها ادامه بده */
}
}
.then((json) => {
if (!cancelled) {
if (json.brand && json.hero && json.footer && json.nav) setData(json)
if (json.brand && json.before_after) setData(json)
else setError(true)
}
})
@@ -286,71 +88,18 @@ export function SiteProvider({ children }: { children: ReactNode }) {
load()
}, [load])
const updateSite = useCallback((mutate: (data: SiteData) => void) => {
setData((prev) => {
if (!prev) return prev
const next = structuredClone(prev)
mutate(next)
return next
})
}, [])
return (
<SiteContext.Provider value={{ data, error, reload, updateSite }}>
{children}
</SiteContext.Provider>
)
return <SiteContext.Provider value={{ data, error, reload }}>{children}</SiteContext.Provider>
}
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 localizeSite(data, lang)
}
const EMPTY_HEADING: SectionHeadingContent = {
id: 0,
key: '',
label: '',
title: '',
lead: '',
image: '',
cta_label: '',
cta_href: '',
}
/** تیتر و توضیح یک بخش بر اساس key تعریف‌شده در پنل مدیریت */
export function useSectionHeading(key: string): SectionHeadingContent {
const { data } = useContext(SiteContext)
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 (
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
)
}
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
export function useStatusLabel(key: string): string {
const { data } = useContext(SiteContext)
const { lang } = useLang()
const localized = data ? localizeSite(data, lang) : null
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
return data
}
+19 -27
View File
@@ -2,52 +2,44 @@ import React from 'react'
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={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={`${t('readArticlePrefix')} ${article.title}`}
to={`/blog/${article.slug}`}
className="group flex h-full flex-col border border-ivory/10 bg-char transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`خواندن مقاله ${article.title}`}
>
<div className="relative overflow-hidden">
<div className="overflow-hidden">
<img
src={article.image}
alt={`${t('articleImagePrefix')} ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
alt={`تصویر مقاله ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105 transform-gpu"
/>
{/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */}
<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>
<div className="flex flex-1 flex-col p-7">
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
<div className="flex flex-1 flex-col p-8">
<p className="text-xs font-light text-copper">{article.category}</p>
<h3 className="mt-4 text-lg font-bold leading-9 text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{article.title}
</h3>
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
<p className="mt-4 mb-8 text-sm font-light leading-[2.2rem] text-ivory/55">
{article.excerpt}
</p>
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
<span className="flex items-center gap-3">
<span>{formatJalali(article.date)}</span>
<div className="mt-auto flex items-center justify-between border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{toPersianDigits(article.read)}
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
</span>
</span>
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<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)]">
{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 className="flex items-center gap-2 text-copper">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 ease-out group-hover:-translate-x-1.5" />
</span>
</div>
</div>
-65
View File
@@ -1,65 +0,0 @@
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>
)
}
-54
View File
@@ -1,54 +0,0 @@
import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
type ArticleMetaProps = {
article: Article
className?: string
}
/**
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده می‌شود:
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
*/
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
const rawDate = (article.date ?? '').trim()
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
const initial = author.charAt(0)
// تاریخ ذخیره‌شده (میلادی) به شمسی تبدیل می‌شود؛ در نبود تاریخ، امروز با ساعت نمایش داده می‌شود
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
const dateLabel = formatJalali(rawDate, todayLabel)
return (
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
{/* آواتار و نام نویسنده */}
<span className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
{initial}
</span>
<span className="flex flex-col">
<span className="text-sm font-bold text-ivory">{author}</span>
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
</span>
</span>
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
{/* تاریخ جلالی */}
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{dateLabel}
</span>
{/* زمان مطالعه — با ارقام فارسی */}
{article.read && (
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{toPersianDigits(article.read)}
</span>
)}
</div>
)
}
+15 -32
View File
@@ -9,33 +9,26 @@ type BaseProps = {
className?: string
}
// استایل‌های فوق‌لوکس شیشه‌ای (Glassmorphic) با رنگ‌های اختصاصی پروژه
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
const STYLES: Record<Variant, { base: string; fill: string; text: string }> = {
copper: {
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
text: 'group-hover:text-char font-semibold',
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
base: 'bg-copper text-char',
fill: 'bg-char',
text: 'group-hover:text-ivory',
},
outline: {
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبه‌ها
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
fill: 'bg-white/10',
text: 'group-hover:text-copper',
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
base: 'border border-ivory/30 text-ivory',
fill: 'bg-copper',
text: 'group-hover:text-char',
},
dark: {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
base: 'bg-char text-ivory',
fill: 'bg-copper',
text: 'group-hover:text-char',
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
},
'dark-ivory': {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
base: 'bg-char text-ivory',
fill: 'bg-ivory',
text: 'group-hover:text-char',
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
},
}
@@ -43,18 +36,12 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
const style = STYLES[variant]
return (
<>
{/* خط انعکاس نوری شیشه در بالای دکمه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
{/* انیمیشن پر شدن موجی و نرم */}
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
className={`absolute inset-0 translate-y-full transition-transform duration-[600ms] ease-lux group-hover:translate-y-0 ${style.fill}`}
/>
{/* متن و آیکون دکمه */}
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-[600ms] ease-lux ${style.text}`}
>
{children}
</span>
@@ -62,9 +49,8 @@ function Inner({ children, variant = 'copper' }: { children: React.ReactNode; va
)
}
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشه‌ای
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
'group relative inline-flex items-center justify-center overflow-hidden px-8 py-4 text-sm font-bold focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink'
export function ButtonLink({
to,
@@ -72,9 +58,8 @@ export function ButtonLink({
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
const style = STYLES[variant]
return (
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
<Link to={to} className={`${SHELL} ${STYLES[variant].base} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
@@ -87,9 +72,8 @@ export function ButtonAnchor({
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const style = STYLES[variant]
return (
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<a href={href} className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
@@ -101,9 +85,8 @@ export function Button({
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const style = STYLES[variant]
return (
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<button className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
-251
View File
@@ -1,251 +0,0 @@
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>
)}
</>
);
}
+53 -121
View File
@@ -1,34 +1,17 @@
import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
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: '', website: '' })
const { brand } = useSite()
const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
const saveContent = (field: string, value: string) =>
save('contact-section', content.id, { [field]: value }, (d) => {
;(d.contact_section as unknown as Record<string, unknown>)[field] = value
})
const saveBrand = (field: string, value: string) =>
save('brand', brand.id, { [field]: value }, (d) => {
;(d.brand as unknown as Record<string, unknown>)[field] = value
})
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
@@ -37,14 +20,14 @@ export function ContactSection() {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: t('understood'),
confirmButtonText: 'متوجه شدم',
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-md bg-ink/70',
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
container: 'backdrop-blur-[8px] bg-ink/60',
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
},
showClass: {
popup: 'swal2-show',
@@ -53,7 +36,7 @@ export function ContactSection() {
hideClass: {
popup: 'swal2-hide',
backdrop: 'swal2-backdrop-hide',
},
}
})
}
@@ -65,13 +48,13 @@ export function ContactSection() {
const phoneValue = form.phone.trim()
if (nameValue.length < 3) {
showErrorAlert(t('incompleteInfo'), t('nameRequired'))
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
return
}
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
if (!phoneValue || !phoneRegex.test(phoneValue)) {
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
return
}
@@ -80,21 +63,20 @@ 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(), website: form.website }),
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
})
if (!res.ok) {
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : t('formCheck')
showErrorAlert(t('submitErrorTitle'), detail)
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در ثبت درخواست', detail)
return
}
track('contact-submit')
setSent(true)
} catch {
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally {
setSending(false)
}
@@ -108,74 +90,53 @@ export function ContactSection() {
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
<div className="relative mx-auto max-w-site px-6 lg:px-10">
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
{/* باکس اطلاعات تماس */}
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
<Reveal>
<p className="text-sm font-light tracking-wide text-copper">
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
</p>
<h2 id="contact-title" className="mt-4 text-section font-bold text-ivory">
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
<p className="text-sm font-light text-copper">تماس با ما</p>
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
پروژه‌تان را با ما در میان بگذارید
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/75">
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
شماره‌تان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس می‌گیرد.
</p>
</Reveal>
<Reveal delay={0.1}>
<ul className="mt-10 space-y-6">
<li className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<MapPinIcon className="h-5 w-5" />
</div>
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} />
</p>
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">
<InlineEdit value={brand.address} onSave={(v) => saveBrand('address', v)} />
</p>
<p className="text-sm font-bold text-ivory">دفتر مرکزی</p>
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
</div>
</li>
<li className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<PhoneIcon className="h-5 w-5" />
</div>
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
</p>
<p className="text-sm font-bold text-ivory">تلفن</p>
<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)} />
{brand.phone}
</a>
</div>
</li>
<li className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<MailIcon className="h-5 w-5" />
</div>
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
</p>
<p className="text-sm font-bold text-ivory">ایمیل</p>
<a
href={`mailto:${brand.email}`}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
{brand.email}
</a>
</div>
</li>
@@ -183,19 +144,13 @@ export function ContactSection() {
</Reveal>
</div>
{/* باکس فرم ارسال درخواست */}
<div className="relative rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-7">
<SingletonPill resource="contact_section" label="ویرایش برچسب‌ها و متن‌های فرم" className="absolute left-4 top-4" />
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
{sent ? (
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper shadow-[0_0_30px_rgba(197,143,92,0.4)]">
<CheckCircle2Icon className="h-8 w-8" />
</div>
<h3 className="mt-6 text-xl font-bold text-ivory">
<InlineEdit value={content.success_title} onSave={(v) => saveContent('success_title', v)} />
</h3>
<CheckCircle2Icon className="h-12 w-12 text-copper" />
<h3 className="mt-6 text-xl font-bold text-ivory">درخواست شما ثبت شد</h3>
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
از اعتماد شما سپاسگزاریم. کارشناسان ما به‌زودی با شما تماس می‌گیرند.
</p>
<Button
type="button"
@@ -203,42 +158,31 @@ export function ContactSection() {
className="mt-8 font-light"
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '', website: '' })
setForm({ name: '', phone: '', note: '' })
}}
>
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
ثبت درخواست جدید
</Button>
</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)} />
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
نام
</label>
<input
id="contact-name"
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={content.name_placeholder}
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 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"
placeholder="نام و نام خانوادگی"
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="contact-phone" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
شماره تلفن <span className="text-copper">*</span>
</label>
<input
id="contact-phone"
@@ -247,40 +191,28 @@ export function ContactSection() {
required
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-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"
placeholder="09xx xxx xxxx"
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div className="md:col-span-2">
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
توضیحات <span className="font-light text-ivory/50">(اختیاری)</span>
</label>
<textarea
id="contact-note"
rows={5}
rows={6}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
placeholder={content.note_placeholder}
className="mt-3 w-full resize-none rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light leading-8 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"
placeholder="درباره پروژه یا درخواست خود بنویسید…"
className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div className="md:col-span-2 flex justify-start">
<Button
type="submit"
variant="copper"
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
disabled={sending}
>
{sending ? (
content.sending_label
) : (
<>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</>
)}
<div className="md:col-span-2">
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
{sending ? 'در حال ارسال…' : 'ارسال درخواست مشاوره'}
</Button>
</div>
</form>

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