From 1e68a848798a8706661a7a8993821b2ae6f8bb41 Mon Sep 17 00:00:00 2001
From: Behrad
Date: Tue, 8 Sep 2026 01:40:00 +0330
Subject: [PATCH] feat: update UI to glassmorphic design and copper theme
---
.env.example | 23 +
.gitignore | 31 +
README.md | 192 +
backend/.env.example | 29 +
backend/Dockerfile | 16 +
backend/README.md | 189 +
backend/app/__init__.py | 0
backend/app/api/__init__.py | 0
backend/app/api/admin.py | 354 ++
backend/app/api/forms.py | 42 +
backend/app/api/public.py | 149 +
backend/app/auth.py | 52 +
backend/app/config.py | 36 +
backend/app/database.py | 20 +
backend/app/main.py | 47 +
backend/app/migrations.py | 71 +
backend/app/models.py | 375 ++
backend/app/rate_limit.py | 49 +
backend/app/schemas.py | 588 ++
backend/app/seed.py | 94 +
backend/app/seed_data.py | 458 ++
backend/app/seo_pages.py | 239 +
backend/requirements-dev.txt | 4 +
backend/requirements.txt | 13 +
backend/tests/conftest.py | 93 +
backend/tests/test_api.py | 182 +
docker-compose.prod.yml | 57 +
docker-compose.yml | 158 +
frontend/.eslintrc.cjs | 18 +
frontend/.gitignore | 24 +
frontend/Caddyfile | 12 +
frontend/Dockerfile | 10 +
frontend/README.md | 33 +
frontend/index.html | 21 +
frontend/package-lock.json | 5203 +++++++++++++++++
frontend/package.json | 43 +
frontend/postcss.config.js | 6 +
frontend/src/App.tsx | 140 +
frontend/src/admin/AdminBar.tsx | 190 +
frontend/src/admin/AdminContext.tsx | 162 +
frontend/src/admin/AdminLoginPage.tsx | 85 +
frontend/src/admin/ArticleWritePage.tsx | 375 ++
frontend/src/admin/Editable.tsx | 290 +
frontend/src/admin/EntityDrawer.tsx | 339 ++
frontend/src/admin/adminClient.ts | 97 +
frontend/src/admin/entities.ts | 203 +
frontend/src/api.tsx | 285 +
frontend/src/components/ArticleCard.tsx | 52 +
frontend/src/components/Button.tsx | 110 +
frontend/src/components/ContactSection.tsx | 277 +
frontend/src/components/FloatingContact.tsx | 30 +
frontend/src/components/Footer.tsx | 152 +
frontend/src/components/Header.tsx | 152 +
frontend/src/components/Lightbox.tsx | 100 +
frontend/src/components/Navbar.tsx | 0
frontend/src/components/PageHero.tsx | 89 +
frontend/src/components/Preloader.tsx | 81 +
frontend/src/components/ProjectCard.tsx | 57 +
frontend/src/components/Reveal.tsx | 23 +
frontend/src/components/SectionHeading.tsx | 58 +
frontend/src/components/ServiceIcon.tsx | 83 +
.../src/components/admin/ArticleEditor.tsx | 267 +
frontend/src/components/admin/ImageField.tsx | 81 +
frontend/src/components/admin/InlineEdit.tsx | 87 +
.../src/components/admin/SeoChecklist.tsx | 208 +
frontend/src/components/home/Articles.tsx | 67 +
frontend/src/components/home/BeforeAfter.tsx | 126 +
frontend/src/components/home/Faq.tsx | 81 +
.../src/components/home/FeaturedProjects.tsx | 125 +
frontend/src/components/home/Gallery.tsx | 123 +
frontend/src/components/home/Hero.tsx | 151 +
frontend/src/components/home/Newsletter.tsx | 161 +
frontend/src/components/home/Services.tsx | 56 +
frontend/src/components/home/Stats.tsx | 70 +
frontend/src/components/home/Story.tsx | 121 +
frontend/src/components/home/Team.tsx | 41 +
frontend/src/components/home/Testimonials.tsx | 80 +
frontend/src/components/home/Timeline.tsx | 92 +
frontend/src/components/home/WhyUs.tsx | 72 +
frontend/src/config.ts | 3 +
frontend/src/data/site.ts | 43 +
frontend/src/index.css | 141 +
frontend/src/index.tsx | 9 +
frontend/src/pages/About.tsx | 24 +
frontend/src/pages/ArticleDetail.tsx | 149 +
frontend/src/pages/Blog.tsx | 132 +
frontend/src/pages/ContactPage.tsx | 39 +
frontend/src/pages/FaqPage.tsx | 16 +
frontend/src/pages/Home.tsx | 38 +
frontend/src/pages/Investment.tsx | 57 +
frontend/src/pages/ProjectDetail.tsx | 173 +
frontend/src/pages/Projects.tsx | 88 +
frontend/src/pages/ServicesPage.tsx | 45 +
frontend/src/seo.ts | 94 +
frontend/src/vite-env.d.ts | 1 +
frontend/tailwind.config.js | 31 +
frontend/tsconfig.json | 25 +
frontend/tsconfig.node.json | 11 +
frontend/vite.config.ts | 20 +
scripts/backup.sh | 44 +
100 files changed, 15553 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 backend/.env.example
create mode 100644 backend/Dockerfile
create mode 100644 backend/README.md
create mode 100644 backend/app/__init__.py
create mode 100644 backend/app/api/__init__.py
create mode 100644 backend/app/api/admin.py
create mode 100644 backend/app/api/forms.py
create mode 100644 backend/app/api/public.py
create mode 100644 backend/app/auth.py
create mode 100644 backend/app/config.py
create mode 100644 backend/app/database.py
create mode 100644 backend/app/main.py
create mode 100644 backend/app/migrations.py
create mode 100644 backend/app/models.py
create mode 100644 backend/app/rate_limit.py
create mode 100644 backend/app/schemas.py
create mode 100644 backend/app/seed.py
create mode 100644 backend/app/seed_data.py
create mode 100644 backend/app/seo_pages.py
create mode 100644 backend/requirements-dev.txt
create mode 100644 backend/requirements.txt
create mode 100644 backend/tests/conftest.py
create mode 100644 backend/tests/test_api.py
create mode 100644 docker-compose.prod.yml
create mode 100644 docker-compose.yml
create mode 100644 frontend/.eslintrc.cjs
create mode 100644 frontend/.gitignore
create mode 100644 frontend/Caddyfile
create mode 100644 frontend/Dockerfile
create mode 100644 frontend/README.md
create mode 100644 frontend/index.html
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/postcss.config.js
create mode 100644 frontend/src/App.tsx
create mode 100644 frontend/src/admin/AdminBar.tsx
create mode 100644 frontend/src/admin/AdminContext.tsx
create mode 100644 frontend/src/admin/AdminLoginPage.tsx
create mode 100644 frontend/src/admin/ArticleWritePage.tsx
create mode 100644 frontend/src/admin/Editable.tsx
create mode 100644 frontend/src/admin/EntityDrawer.tsx
create mode 100644 frontend/src/admin/adminClient.ts
create mode 100644 frontend/src/admin/entities.ts
create mode 100644 frontend/src/api.tsx
create mode 100644 frontend/src/components/ArticleCard.tsx
create mode 100644 frontend/src/components/Button.tsx
create mode 100644 frontend/src/components/ContactSection.tsx
create mode 100644 frontend/src/components/FloatingContact.tsx
create mode 100644 frontend/src/components/Footer.tsx
create mode 100644 frontend/src/components/Header.tsx
create mode 100644 frontend/src/components/Lightbox.tsx
create mode 100644 frontend/src/components/Navbar.tsx
create mode 100644 frontend/src/components/PageHero.tsx
create mode 100644 frontend/src/components/Preloader.tsx
create mode 100644 frontend/src/components/ProjectCard.tsx
create mode 100644 frontend/src/components/Reveal.tsx
create mode 100644 frontend/src/components/SectionHeading.tsx
create mode 100644 frontend/src/components/ServiceIcon.tsx
create mode 100644 frontend/src/components/admin/ArticleEditor.tsx
create mode 100644 frontend/src/components/admin/ImageField.tsx
create mode 100644 frontend/src/components/admin/InlineEdit.tsx
create mode 100644 frontend/src/components/admin/SeoChecklist.tsx
create mode 100644 frontend/src/components/home/Articles.tsx
create mode 100644 frontend/src/components/home/BeforeAfter.tsx
create mode 100644 frontend/src/components/home/Faq.tsx
create mode 100644 frontend/src/components/home/FeaturedProjects.tsx
create mode 100644 frontend/src/components/home/Gallery.tsx
create mode 100644 frontend/src/components/home/Hero.tsx
create mode 100644 frontend/src/components/home/Newsletter.tsx
create mode 100644 frontend/src/components/home/Services.tsx
create mode 100644 frontend/src/components/home/Stats.tsx
create mode 100644 frontend/src/components/home/Story.tsx
create mode 100644 frontend/src/components/home/Team.tsx
create mode 100644 frontend/src/components/home/Testimonials.tsx
create mode 100644 frontend/src/components/home/Timeline.tsx
create mode 100644 frontend/src/components/home/WhyUs.tsx
create mode 100644 frontend/src/config.ts
create mode 100644 frontend/src/data/site.ts
create mode 100644 frontend/src/index.css
create mode 100644 frontend/src/index.tsx
create mode 100644 frontend/src/pages/About.tsx
create mode 100644 frontend/src/pages/ArticleDetail.tsx
create mode 100644 frontend/src/pages/Blog.tsx
create mode 100644 frontend/src/pages/ContactPage.tsx
create mode 100644 frontend/src/pages/FaqPage.tsx
create mode 100644 frontend/src/pages/Home.tsx
create mode 100644 frontend/src/pages/Investment.tsx
create mode 100644 frontend/src/pages/ProjectDetail.tsx
create mode 100644 frontend/src/pages/Projects.tsx
create mode 100644 frontend/src/pages/ServicesPage.tsx
create mode 100644 frontend/src/seo.ts
create mode 100644 frontend/src/vite-env.d.ts
create mode 100644 frontend/tailwind.config.js
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/tsconfig.node.json
create mode 100644 frontend/vite.config.ts
create mode 100644 scripts/backup.sh
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0369333
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,23 @@
+# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
+# 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"]
+
+# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
+# DOMAIN=aramesh.example
+# ACME_EMAIL=admin@aramesh.example
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..da36e75
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,31 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
+env/
+
+# Environment
+.env
+
+# پشتیبانگیری محلی
+backups/
+
+# Node / Vite
+node_modules/
+dist/
+.vite/
+
+# IDE / OS
+.idea/
+.vscode/
+.DS_Store
+Thumbs.db
+
+# Database
+*.sqlite3
+*.db
+
+# تصاویر محلی — از پنل/کنسول MinIO مدیریت میشوند
+frontend/public/images/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0a77754
--- /dev/null
+++ b/README.md
@@ -0,0 +1,192 @@
+# گروه ساختمانی آرامش — پروژه کامل (فرانتاند + بکاند)
+
+مونوریپوی سایت گروه ساختمانی آرامش شامل:
+
+- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
+- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO
+
+## معماری
+
+```
+Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
+├── / → Caddy (فایلهای استاتیک SPA)
+├── /api → FastAPI (بکاند)
+└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
+```
+
+- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
+ با یک درخواست `GET /api/site` بارگذاری و از پنل مدیریت ویرایش میشود
+- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
+ `/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
+- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
+
+## ساختار
+
+```
+├── frontend/ # سایت React
+│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
+│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
+│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
+├── backend/ # بکاند FastAPI
+│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
+│ ├── tests/ # تستهای API (pytest)
+│ ├── requirements.txt
+│ ├── Dockerfile
+│ └── .env.example
+├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
+├── docker-compose.yml # اجرای کامل با یک دستور
+└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
+```
+
+## اجرا با Docker (سادهترین راه)
+
+```bash
+cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
+docker compose up --build
+```
+
+- سایت: http://localhost:5173 (از طریق Traefik)
+- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
+- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در compose
+- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
+- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
+ ارتقای نسخه دادههای فعلی را از بین نمیبرد
+- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد و خواندن عمومی را فعال میکند
+
+## انتشار واقعی (TLS)
+
+برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
+
+```bash
+# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
+docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
+```
+
+- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
+- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرسهای OG مطلق شوند
+
+## اجرای محلی (توسعه)
+
+### بکاند
+
+```bash
+cd backend
+python -m venv .venv
+.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate
+pip install -r requirements.txt
+
+# فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
+python -m app.seed # ساخت جداول + محتوای اولیه
+uvicorn app.main:app --reload
+```
+
+### فرانتاند
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
+پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
+`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
+
+## امکانات بکاند
+
+- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
+ تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
+- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
+- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
+ و سپس CRUD کامل همه محتواها در `/api/admin/...`
+- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
+- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
+- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
+ ذخیره و URL نسبی `/media/...` برمیگردد
+- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
+- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
+- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
+- **تستها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
+
+> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید
+> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
+
+## ویرایش زنده سایت (دوقلوی دیجیتال)
+
+ادمینها نیازی به داشبورد جدا ندارند — خود سایت ویرایشگر است:
+
+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 تبدیل میکند، پس فقط تگهای شناختهشده
+(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
+در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
+
+## سئو
+
+- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
+ طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
+ تیتربندی 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/backup.sh # در backups/ — نگهداری پیشفرض ۱۴ روز
+```
+
+- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
+- زمانبندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
+- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
+
+## نکات امنیتی
+
+- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
+ با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
+- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
+- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
+- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
+- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
+- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..1429894
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,29 @@
+# کپی کنید و نام آن را بگذارید .env — مقادیر را با تنظیمات خودتان جایگزین کنید
+
+# رشته اتصال به PostgreSQL (پسورد، نام کاربری و نام دیتابیس را تنظیم کنید)
+DATABASE_URL=postgresql+psycopg://postgres:YOUR_PASSWORD@localhost:5432/arameshplaza_api
+
+# کلید امضای توکن JWT — حتماً یک مقدار تصادفی طولانی بگذارید
+SECRET_KEY=change-me-to-a-long-random-string
+
+# مدت اعتبار توکن مدیریت بر حسب دقیقه (پیشفرض: ۲۴ ساعت)
+ACCESS_TOKEN_EXPIRE_MINUTES=1440
+
+# آدرسهای مجاز برای CORS (لیست JSON — آدرس فرانتاند خود را اضافه کنید)
+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
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..b060621
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:3.13-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app ./app
+
+EXPOSE 8000
+
+# ساخت جداول و seed (بدون حذف دادههای موجود) و سپس اجرای سرور
+CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..7f6b098
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,189 @@
+# بکاند گروه ساختمانی آرامش (Aramesh Building Group API)
+
+بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی).
+
+## امکانات
+
+- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخشها، هیرو صفحات، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، قبل/بعد، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متنهای سایت در دیتابیس است**
+- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
+- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
+- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیامهای تماس و مشترکین
+- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمیگردد
+- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد (فقط جدولهای خالی پر میشوند — ارتقا بدون از دست رفتن داده)
+- مستندات تعاملی Swagger در مسیر `/docs`
+
+## راهاندازی
+
+### ۱. پیشنیازها
+
+- Python 3.11 یا جدیدتر
+- PostgreSQL در حال اجرا (پورت پیشفرض 5432)
+
+### ۲. نصب وابستگیها
+
+```bash
+cd arameshplaza-backend
+python -m venv .venv
+
+# ویندوز
+.venv\Scripts\activate
+# لینوکس / مک
+source .venv/bin/activate
+
+pip install -r requirements.txt
+```
+
+### ۳. تنظیمات
+
+فایل `.env.example` را به نام `.env` کپی کنید و مقادیر را تنظیم کنید:
+
+```bash
+cp .env.example .env # لینوکس / مک
+copy .env.example .env # ویندوز
+```
+
+مهمترین تنظیم `DATABASE_URL` است:
+
+```
+DATABASE_URL=postgresql+psycopg://postgres:YOUR_PASSWORD@localhost:5432/arameshplaza_api
+```
+
+### ۴. ساخت دیتابیس و Seed
+
+```bash
+# ساخت دیتابیس (یکبار):
+psql -U postgres -c "CREATE DATABASE arameshplaza_api;"
+
+# ساخت جداول + وارد کردن محتوای سایت + ساخت ادمین پیشفرض:
+python -m app.seed
+
+# بازنشانی کامل دادهها در صورت نیاز:
+python -m app.seed --force
+```
+
+### ۵. اجرا
+
+```bash
+uvicorn app.main:app --reload
+```
+
+سپس:
+- مستندات API: http://localhost:8000/docs
+- بررسی سلامت: http://localhost:8000/api/health
+
+## ورود به پنل مدیریت
+
+```
+POST /api/auth/login
+{ "username": "admin", "password": "aramesh123" }
+```
+
+پاسخ شامل `access_token` است. توکن را در هدر همه درخواستهای مدیریتی قرار دهید:
+
+```
+Authorization: Bearer
+```
+
+> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
+
+تغییر رمز (با توکن):
+
+```
+POST /api/auth/change-password
+{ "current_password": "...", "new_password": "..." }
+```
+
+## فهرست انتهاپوینتها
+
+### عمومی (بدون نیاز به توکن)
+
+| متد | مسیر | توضیح |
+|---|---|---|
+| GET | `/api/health` | وضعیت سرور |
+| GET | `/api/brand` | اطلاعات برند و تماس |
+| GET | `/api/services` | خدمات |
+| GET | `/api/projects` | همه پروژهها |
+| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
+| GET | `/api/articles` | مقالههای منتشرشده (پیشنویس نمیآید) |
+| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
+| GET | `/api/testimonials` | نظرات مشتریان |
+| GET | `/api/faqs` | سوالات متداول |
+| GET | `/api/team` | اعضای تیم |
+| GET | `/api/timeline` | تایملاین شرکت |
+| GET | `/api/stats` | آمار |
+| GET | `/api/why-us` | چرا گروه ساختمانی آرامش |
+| GET | `/api/before-after` | بخش قبل/بعد |
+| 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 صفحه پروژه با متای سئوی تزریقشده |
+
+### مدیریت (نیاز به توکن Bearer)
+
+برای هر یک از این مسیرها، CRUD کامل در دسترس است:
+`brand`، `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `before-after`
+
+| متد | مسیر | توضیح |
+|---|---|---|
+| 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 | `/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)
+- تصاویر عمومی و قابل نمایشاند؛ آپلود فقط با توکن ادمین انجام میشود.
+
+## اتصال به فرانتاند
+
+در پروژه React (پوشه `ArameshPlaza`)، فایل `src/config.ts` آدرس بکاند را نگه میدارد:
+
+```ts
+export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
+```
+
+در محیط تولید مقدار `VITE_API_URL` را روی آدرس عمومی API تنظیم کنید و مطمئن شوید آدرس سایت در `CORS_ORIGINS` فایل `.env` بکاند قرار دارد.
+
+## ساختار پروژه
+
+```
+app/
+├── main.py # نقطه ورود FastAPI، CORS، ثبت روترها
+├── config.py # تنظیمات از .env
+├── database.py # اتصال PostgreSQL و نشستها
+├── models.py # مدلهای SQLAlchemy
+├── schemas.py # اسکیماهای Pydantic (ورودی/خروجی)
+├── auth.py # هش رمز عبور و JWT
+├── seed.py # اسکریپت seed
+├── seed_data.py # دادههای اولیه سایت
+└── api/
+ ├── public.py # انتهای عمومی محتوا
+ ├── forms.py # فرم تماس و خبرنامه
+ └── admin.py # ورود و پنل مدیریت
+```
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
new file mode 100644
index 0000000..eedbea4
--- /dev/null
+++ b/backend/app/api/admin.py
@@ -0,0 +1,354 @@
+"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
+
+import uuid
+from datetime import datetime, timezone
+
+import boto3
+from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
+from sqlalchemy import func
+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 ..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,
+)
+
+# روتر عمومی برای ورود — بدون نیاز به توکن
+auth_router = APIRouter()
+
+# روتر محافظتشده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
+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)
+ 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 (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
+ حذف و ترتیببندی) روی روتر مدیریت."""
+
+ @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()
+
+ @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()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
+ db.refresh(item)
+ return item
+
+ @router.get(f"/{path}/{{item_id}}", response_model=out_schema)
+ def get_item(item_id: int, db: Session = Depends(get_db)):
+ item = db.get(model, item_id)
+ if item is None:
+ raise HTTPException(status_code=404, detail="رکورد یافت نشد")
+ return item
+
+ @router.put(f"/{path}/{{item_id}}", response_model=out_schema)
+ def update_item(item_id: int, payload: create_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().items():
+ setattr(item, key, value)
+ try:
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
+ 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)
+ if item is None:
+ raise HTTPException(status_code=404, detail="رکورد یافت نشد")
+ db.delete(item)
+ db.commit()
+
+
+# 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",
+}
+
+
+@router.post("/upload")
+async def upload_media(
+ file: UploadFile = File(...),
+ _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} مگابایت است",
+ )
+
+ now = datetime.now(timezone.utc)
+ key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{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 {
+ "url": f"{settings.media_public_base}/{key}",
+ "key": key,
+ "size": len(data),
+ "content_type": content_type,
+ }
+
+
+# پیامهای تماس
+@router.get("/messages", response_model=list[ContactMessageOut])
+def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
+ query = db.query(models.ContactMessage)
+ if unread_only:
+ query = query.filter(models.ContactMessage.is_read.is_(False))
+ return query.order_by(models.ContactMessage.created_at.desc()).all()
+
+
+@router.patch("/messages/{message_id}", response_model=ContactMessageOut)
+def update_message(message_id: int, payload: MessageReadIn, db: Session = Depends(get_db)):
+ message = db.get(models.ContactMessage, message_id)
+ if message is None:
+ raise HTTPException(status_code=404, detail="پیام یافت نشد")
+ message.is_read = payload.is_read
+ db.commit()
+ db.refresh(message)
+ return message
+
+
+@router.delete("/messages/{message_id}", status_code=204)
+def delete_message(message_id: int, db: Session = Depends(get_db)):
+ message = db.get(models.ContactMessage, message_id)
+ if message is None:
+ raise HTTPException(status_code=404, detail="پیام یافت نشد")
+ db.delete(message)
+ db.commit()
+
+
+# مشترکین خبرنامه
+@router.get("/subscribers", response_model=list[SubscriberOut])
+def list_subscribers(db: Session = Depends(get_db)):
+ return db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
+
+
+@router.delete("/subscribers/{subscriber_id}", status_code=204)
+def delete_subscriber(subscriber_id: int, db: Session = Depends(get_db)):
+ subscriber = db.get(models.Subscriber, subscriber_id)
+ if subscriber is None:
+ raise HTTPException(status_code=404, detail="مشترک یافت نشد")
+ db.delete(subscriber)
+ db.commit()
diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py
new file mode 100644
index 0000000..9692b40
--- /dev/null
+++ b/backend/app/api/forms.py
@@ -0,0 +1,42 @@
+"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه."""
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from .. import models
+from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
+
+router = APIRouter()
+
+
+@router.post("/contact", response_model=ContactMessageOut, status_code=201)
+def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
+ message = models.ContactMessage(
+ name=payload.name.strip(),
+ phone=payload.phone.strip(),
+ note=payload.note.strip(),
+ )
+ db.add(message)
+ db.commit()
+ db.refresh(message)
+ return message
+
+
+@router.post("/newsletter", response_model=SubscriberOut, status_code=201)
+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()
+
+ if subscriber is not None:
+ if not subscriber.is_active:
+ subscriber.is_active = True
+ db.commit()
+ db.refresh(subscriber)
+ return subscriber
+
+ subscriber = models.Subscriber(email=email)
+ db.add(subscriber)
+ db.commit()
+ db.refresh(subscriber)
+ return subscriber
diff --git a/backend/app/api/public.py b/backend/app/api/public.py
new file mode 100644
index 0000000..25a312d
--- /dev/null
+++ b/backend/app/api/public.py
@@ -0,0 +1,149 @@
+"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from .. import models
+from ..schemas import (
+ ArticleOut,
+ BeforeAfterOut,
+ BrandOut,
+ FaqOut,
+ ProjectOut,
+ ServiceOut,
+ StatOut,
+ TeamMemberOut,
+ TestimonialOut,
+ TimelineItemOut,
+ WhyUsItemOut,
+)
+
+router = APIRouter()
+
+
+@router.get("/health")
+def health():
+ return {"status": "ok", "app": "Aramesh Building Group API"}
+
+
+@router.get("/brand", response_model=BrandOut)
+def get_brand(db: Session = Depends(get_db)):
+ brand = db.query(models.Brand).first()
+ if brand is None:
+ raise HTTPException(status_code=404, detail="اطلاعات برند یافت نشد")
+ return brand
+
+
+@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()
+
+
+@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()
+
+
+@router.get("/projects/{slug}", response_model=ProjectOut)
+def get_project(slug: str, db: Session = Depends(get_db)):
+ project = db.query(models.Project).filter(models.Project.slug == slug).first()
+ if project is None:
+ raise HTTPException(status_code=404, detail="پروژه یافت نشد")
+ return project
+
+
+@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()
+ )
+
+
+@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()
+ )
+ if article is None:
+ raise HTTPException(status_code=404, detail="مقاله یافت نشد")
+ return article
+
+
+@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()
+
+
+@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()
+
+
+@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()
+
+
+@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()
+
+
+@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()
+
+
+@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()
+
+
+@router.get("/before-after", response_model=BeforeAfterOut)
+def get_before_after(db: Session = Depends(get_db)):
+ item = db.query(models.BeforeAfter).first()
+ if item is None:
+ raise HTTPException(status_code=404, detail="بخش قبل/بعد یافت نشد")
+ return item
+
+
+@router.get("/site")
+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(),
+ "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(),
+ }
diff --git a/backend/app/auth.py b/backend/app/auth.py
new file mode 100644
index 0000000..0532e31
--- /dev/null
+++ b/backend/app/auth.py
@@ -0,0 +1,52 @@
+from datetime import datetime, timedelta, timezone
+
+import bcrypt
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from sqlalchemy.orm import Session
+
+from .config import settings
+from .database import get_db
+from .models import AdminUser
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
+
+
+def hash_password(password: str) -> str:
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(password: str, hashed: str) -> bool:
+ return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
+
+
+def create_access_token(username: str) -> str:
+ expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
+ payload = {"sub": username, "exp": expire}
+ return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
+
+
+def get_current_admin(
+ token: str = Depends(oauth2_scheme),
+ db: Session = Depends(get_db),
+) -> AdminUser:
+ """وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
+ credentials_error = HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="احراز هویت نامعتبر است",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ try:
+ payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
+ except jwt.PyJWTError:
+ raise credentials_error
+
+ username = payload.get("sub")
+ if not username:
+ raise credentials_error
+
+ user = db.query(AdminUser).filter(AdminUser.username == username).first()
+ if user is None:
+ raise credentials_error
+ return user
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..2d97aeb
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,36 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ """تنظیمات برنامه — از متغیرهای محیطی یا فایل .env خوانده میشود."""
+
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ extra="ignore",
+ )
+
+ database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
+ secret_key: str = "dev-secret-change-me-in-production"
+ algorithm: str = "HS256"
+ access_token_expire_minutes: int = 1440
+ 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"
+
+
+settings = Settings()
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..e1eee4c
--- /dev/null
+++ b/backend/app/database.py
@@ -0,0 +1,20 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import DeclarativeBase, sessionmaker
+
+from .config import settings
+
+engine = create_engine(settings.database_url, pool_pre_ping=True)
+SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db():
+ """وابستگی FastAPI برای تزریق نشست دیتابیس."""
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..e04fea7
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,47 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
+from .api import admin, forms, public
+from .config import settings
+from .database import Base, engine
+from .migrations import run_all as run_migrations
+from .seo_pages import router as seo_router
+
+Base.metadata.create_all(bind=engine)
+run_migrations()
+
+app = FastAPI(
+ title="Aramesh Building Group API",
+ description=(
+ "بکاند سایت گروه ساختمانی آرامش — API محتوای سایت، فرم تماس و خبرنامه، "
+ "و پنل مدیریت با احراز هویت JWT."
+ ),
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc",
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins,
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+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=["سئو"])
+
+
+@app.get("/")
+def root():
+ return {
+ "app": "Aramesh Building Group API",
+ "docs": "/docs",
+ "health": "/api/health",
+ }
diff --git a/backend/app/migrations.py b/backend/app/migrations.py
new file mode 100644
index 0000000..3e4cd48
--- /dev/null
+++ b/backend/app/migrations.py
@@ -0,0 +1,71 @@
+"""مهاجرتهای سبک دیتابیس — 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",
+]
+
+
+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 ''")
+ )
+
+
+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 run_all() -> None:
+ run_ddl_migrations()
+ migrate_article_bodies()
diff --git a/backend/app/models.py b/backend/app/models.py
new file mode 100644
index 0000000..f5929a7
--- /dev/null
+++ b/backend/app/models.py
@@ -0,0 +1,375 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from .database import Base
+
+
+def utcnow() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+class AdminUser(Base):
+ __tablename__ = "admin_users"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+ password_hash: Mapped[str] = mapped_column(String(255))
+
+
+class Brand(Base):
+ __tablename__ = "brand"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: 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="")
+ phone: Mapped[str] = mapped_column(String(64), default="")
+ phone_href: Mapped[str] = mapped_column(String(128), default="")
+ email: Mapped[str] = mapped_column(String(128), default="")
+ address: 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))
+ desc: 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))
+ location: Mapped[str] = mapped_column(String(128), default="")
+ year: 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="")
+ floors: Mapped[str] = mapped_column(String(64), default="")
+ units: Mapped[str] = mapped_column(String(64), default="")
+ client: Mapped[str] = mapped_column(String(128), default="")
+ summary: Mapped[str] = mapped_column(Text, default="")
+ body: 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))
+ date: Mapped[str] = mapped_column(String(64), default="")
+ category: Mapped[str] = mapped_column(String(64), default="")
+ image: Mapped[str] = mapped_column(Text, default="")
+ read: Mapped[str] = mapped_column(String(32), default="")
+ excerpt: Mapped[str] = mapped_column(Text, default="")
+ body: Mapped[list] = mapped_column(JSON, default=list)
+ # draft | published — پیشنویس فقط برای ادمین دیده میشود
+ status: Mapped[str] = mapped_column(String(16), default="published")
+ # کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
+ seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
+
+
+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))
+ role: Mapped[str] = mapped_column(String(128), default="")
+ quote: 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)
+ answer: 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))
+ role: Mapped[str] = mapped_column(String(128), default="")
+ bio: 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))
+ title: Mapped[str] = mapped_column(String(256))
+ desc: 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="")
+ label: Mapped[str] = mapped_column(String(128))
+
+
+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))
+ desc: Mapped[str] = mapped_column(Text, default="")
+
+
+class BeforeAfter(Base):
+ __tablename__ = "before_after"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(256), default="")
+ lead: 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="")
+ after_label: 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))
+ 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="")
+ title: Mapped[str] = mapped_column(Text, default="")
+ title_highlight: Mapped[str] = mapped_column(String(128), default="")
+ subtitle: Mapped[str] = mapped_column(Text, default="")
+ badge_value: Mapped[str] = mapped_column(String(32), default="")
+ badge_label: Mapped[str] = mapped_column(Text, default="")
+ cta_primary_label: 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_href: Mapped[str] = mapped_column(String(128), default="")
+ scroll_label: 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="")
+ paragraphs: Mapped[list] = mapped_column(JSON, default=list)
+ quote: Mapped[str] = mapped_column(Text, default="")
+ quote_author: Mapped[str] = mapped_column(Text, default="")
+ stat1_value: Mapped[str] = mapped_column(String(32), default="")
+ stat1_label: Mapped[str] = mapped_column(String(128), default="")
+ stat2_value: Mapped[str] = mapped_column(String(32), default="")
+ stat2_label: Mapped[str] = mapped_column(String(128), default="")
+ link_label: 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}]
+
+
+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="")
+ title: Mapped[str] = mapped_column(String(256), default="")
+ lead: Mapped[str] = mapped_column(Text, default="")
+ image: Mapped[str] = mapped_column(Text, default="")
+ cta_label: 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="")
+ lead: 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))
+ desc: Mapped[str] = mapped_column(Text, default="")
+ points: 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="")
+ title: Mapped[str] = mapped_column(String(256))
+ desc: 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))
+ address: Mapped[str] = mapped_column(Text, default="")
+ hours: 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="")
+
+
+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))
+
+
+class ContactSectionContent(Base):
+ """بخش فرم تماس — رکورد یگانه."""
+
+ __tablename__ = "contact_section"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ label: Mapped[str] = mapped_column(String(64), default="")
+ title: Mapped[str] = mapped_column(String(256), default="")
+ lead: Mapped[str] = mapped_column(Text, default="")
+ address_label: Mapped[str] = mapped_column(String(64), default="")
+ phone_label: Mapped[str] = mapped_column(String(64), default="")
+ email_label: Mapped[str] = mapped_column(String(64), default="")
+ name_label: Mapped[str] = mapped_column(String(64), default="")
+ name_placeholder: Mapped[str] = mapped_column(String(128), default="")
+ phone_field_label: Mapped[str] = mapped_column(String(64), default="")
+ phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
+ note_label: Mapped[str] = mapped_column(String(64), default="")
+ note_optional_label: Mapped[str] = mapped_column(String(64), default="")
+ note_placeholder: Mapped[str] = mapped_column(String(256), default="")
+ submit_label: Mapped[str] = mapped_column(String(128), default="")
+ sending_label: Mapped[str] = mapped_column(String(64), default="")
+ success_title: Mapped[str] = mapped_column(String(256), default="")
+ success_text: Mapped[str] = mapped_column(Text, default="")
+ success_button_label: 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="")
+ lead: Mapped[str] = mapped_column(Text, default="")
+ placeholder: Mapped[str] = mapped_column(String(128), default="")
+ button_label: Mapped[str] = mapped_column(String(64), default="")
+ sending_label: Mapped[str] = mapped_column(String(64), default="")
+ success_text: 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="")
+ pages_heading: Mapped[str] = mapped_column(String(64), default="")
+ services_heading: Mapped[str] = mapped_column(String(64), default="")
+ contact_heading: Mapped[str] = mapped_column(String(64), default="")
+ copyright: Mapped[str] = mapped_column(String(256), default="")
+ tagline: Mapped[str] = mapped_column(String(256), default="")
+ socials: 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="")
+ description: Mapped[str] = mapped_column(Text, default="")
+
+
+class ContactMessage(Base):
+ __tablename__ = "contact_messages"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(128))
+ phone: Mapped[str] = mapped_column(String(32))
+ note: Mapped[str] = mapped_column(Text, default="")
+ is_read: Mapped[bool] = mapped_column(Boolean, default=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class Subscriber(Base):
+ __tablename__ = "subscribers"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ email: Mapped[str] = mapped_column(String(256), unique=True, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py
new file mode 100644
index 0000000..ec35dc3
--- /dev/null
+++ b/backend/app/rate_limit.py
@@ -0,0 +1,49 @@
+"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
+
+پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
+شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
+"""
+
+import time
+
+from fastapi import HTTPException, Request, status
+
+MAX_FAILED_ATTEMPTS = 5
+WINDOW_SECONDS = 15 * 60
+
+_failed: dict[str, list[float]] = {}
+
+
+def client_ip(request: Request) -> str:
+ forwarded = request.headers.get("x-forwarded-for")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
+
+
+def _prune(key: str, now: float) -> list[float]:
+ recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
+ _failed[key] = recent
+ return recent
+
+
+def ensure_allowed(request: Request) -> None:
+ """اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
+ key = client_ip(request)
+ recent = _prune(key, time.monotonic())
+ if len(recent) >= MAX_FAILED_ATTEMPTS:
+ retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
+ 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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
new file mode 100644
index 0000000..0e5811e
--- /dev/null
+++ b/backend/app/schemas.py
@@ -0,0 +1,588 @@
+from datetime import datetime
+from typing import Literal, Optional
+
+from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
+
+# ---------- پایه ----------
+
+
+class ORMModel(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+
+# ---------- خروجیها ----------
+
+
+class BrandOut(ORMModel):
+ id: int
+ name: str
+ logo: str
+ hero: str
+ contact_bg: str
+ phone: str
+ phone_href: str
+ email: str
+ address: str
+
+
+class ServiceOut(ORMModel):
+ id: int
+ title: str
+ desc: str
+ icon: str
+
+
+class ProjectOut(ORMModel):
+ id: int
+ slug: str
+ title: str
+ location: str
+ year: str
+ status: str
+ image: str
+ gallery: list[str]
+ area: str
+ floors: str
+ units: str
+ client: str
+ summary: str
+ body: list[str]
+
+
+class ArticleOut(ORMModel):
+ id: int
+ slug: str
+ title: str
+ date: str
+ category: str
+ image: str
+ read: str
+ excerpt: str
+ body: dict
+ status: str
+ seo_keyphrase: str
+
+
+class TestimonialOut(ORMModel):
+ id: int
+ name: str
+ role: str
+ quote: str
+
+
+class FaqOut(ORMModel):
+ id: int
+ question: str
+ answer: str
+
+
+class TeamMemberOut(ORMModel):
+ id: int
+ name: str
+ role: str
+ bio: str
+
+
+class TimelineItemOut(ORMModel):
+ id: int
+ year: str
+ title: str
+ desc: str
+ image: str
+
+
+class StatOut(ORMModel):
+ id: int
+ value: int
+ suffix: str
+ label: str
+
+
+class WhyUsItemOut(ORMModel):
+ id: int
+ title: str
+ desc: str
+
+
+class BeforeAfterOut(ORMModel):
+ id: int
+ title: str
+ lead: str
+ before: str
+ after: str
+ before_label: str
+ after_label: str
+
+
+class NavItemOut(ORMModel):
+ id: int
+ label: str
+ href: str
+
+
+class HeroOut(ORMModel):
+ id: int
+ kicker: str
+ title: str
+ title_highlight: str
+ subtitle: str
+ badge_value: str
+ badge_label: str
+ cta_primary_label: str
+ cta_primary_href: str
+ cta_secondary_label: str
+ cta_secondary_href: str
+ scroll_label: str
+
+
+class StoryOut(ORMModel):
+ id: int
+ title: str
+ paragraphs: list[str]
+ quote: str
+ quote_author: str
+ stat1_value: str
+ stat1_label: str
+ stat2_value: str
+ stat2_label: str
+ link_label: str
+ link_href: str
+ bento: list[dict]
+
+
+class SectionHeadingOut(ORMModel):
+ id: int
+ key: str
+ label: str
+ title: str
+ lead: str
+ image: str
+ cta_label: str
+ cta_href: str
+
+
+class PageHeroOut(ORMModel):
+ id: int
+ key: str
+ title: str
+ lead: str
+ image: str
+
+
+class InvestmentModelOut(ORMModel):
+ id: int
+ title: str
+ desc: str
+ points: list[str]
+
+
+class ProcessStepOut(ORMModel):
+ id: int
+ step: str
+ title: str
+ desc: str
+
+
+class OfficeOut(ORMModel):
+ id: int
+ city: str
+ address: str
+ hours: str
+
+
+class GalleryImageOut(ORMModel):
+ id: int
+ src: str
+ alt: str
+
+
+class StatusLabelOut(ORMModel):
+ id: int
+ key: str
+ label: str
+
+
+class ContactSectionOut(ORMModel):
+ id: int
+ label: str
+ title: str
+ lead: str
+ address_label: str
+ phone_label: str
+ email_label: str
+ name_label: str
+ name_placeholder: str
+ phone_field_label: str
+ phone_placeholder: str
+ note_label: str
+ note_optional_label: str
+ note_placeholder: str
+ submit_label: str
+ sending_label: str
+ success_title: str
+ success_text: str
+ success_button_label: str
+
+
+class NewsletterOut(ORMModel):
+ id: int
+ title: str
+ lead: str
+ placeholder: str
+ button_label: str
+ sending_label: str
+ success_text: str
+
+
+class FooterOut(ORMModel):
+ id: int
+ about_text: str
+ pages_heading: str
+ services_heading: str
+ contact_heading: str
+ copyright: str
+ tagline: str
+ socials: list[dict]
+
+
+class SeoMetaOut(ORMModel):
+ id: int
+ page_key: str
+ title: str
+ description: str
+
+
+class ContactMessageOut(ORMModel):
+ id: int
+ name: str
+ phone: str
+ note: str
+ is_read: bool
+ created_at: datetime
+
+
+class SubscriberOut(ORMModel):
+ id: int
+ email: str
+ is_active: bool
+ created_at: datetime
+
+
+# ---------- ورودی فرمها ----------
+
+
+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)
+
+
+class NewsletterIn(BaseModel):
+ email: EmailStr
+
+
+# ---------- ورودیهای پنل مدیریت ----------
+
+
+class LoginIn(BaseModel):
+ username: str
+ password: str
+
+
+class TokenOut(BaseModel):
+ access_token: str
+ 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 = ""
+ logo: str = ""
+ hero: str = ""
+ contact_bg: str = ""
+ phone: str = ""
+ phone_href: str = ""
+ email: str = ""
+ address: str = ""
+
+
+class ServiceIn(BaseModel):
+ title: str = Field(min_length=1, max_length=256)
+ desc: 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)
+ location: str = ""
+ year: str = ""
+ status: Literal["done", "progress"] = "done"
+ image: str = ""
+ gallery: list[str] = []
+ area: str = ""
+ floors: str = ""
+ units: str = ""
+ client: str = ""
+ summary: str = ""
+ body: list[str] = []
+
+
+EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
+
+
+def validate_doc_body(value: object) -> dict:
+ """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
+ if not value:
+ return dict(EMPTY_DOC)
+ if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
+ raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
+ return value
+
+
+class ArticleIn(BaseModel):
+ slug: str = Field(min_length=1, max_length=128)
+ title: str = Field(min_length=1, max_length=256)
+ date: str = ""
+ category: str = ""
+ image: str = ""
+ read: str = ""
+ excerpt: str = ""
+ body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
+ status: Literal["draft", "published"] = "draft"
+ seo_keyphrase: str = Field(default="", max_length=128)
+ _validate_body = field_validator("body")(validate_doc_body)
+
+
+class TestimonialIn(BaseModel):
+ name: str = Field(min_length=1, max_length=128)
+ role: str = ""
+ quote: str = ""
+
+
+class FaqIn(BaseModel):
+ question: str = Field(min_length=1)
+ answer: str = ""
+
+
+class TeamMemberIn(BaseModel):
+ name: str = Field(min_length=1, max_length=128)
+ role: str = ""
+ bio: str = ""
+
+
+class TimelineItemIn(BaseModel):
+ year: str = Field(min_length=1, max_length=32)
+ title: str = Field(min_length=1, max_length=256)
+ desc: str = ""
+ image: str = ""
+
+
+class StatIn(BaseModel):
+ value: int
+ suffix: str = ""
+ label: str = Field(min_length=1, max_length=128)
+
+
+class WhyUsItemIn(BaseModel):
+ title: str = Field(min_length=1, max_length=256)
+ desc: str = ""
+
+
+class BeforeAfterIn(BaseModel):
+ title: str = ""
+ lead: str = ""
+ before: str = ""
+ after: str = ""
+ before_label: str = ""
+ after_label: str = ""
+
+
+class NavItemIn(BaseModel):
+ label: str = Field(min_length=1, max_length=64)
+ href: str = Field(min_length=1, max_length=128)
+
+
+class HeroIn(BaseModel):
+ kicker: str = ""
+ title: str = ""
+ title_highlight: str = ""
+ subtitle: str = ""
+ badge_value: str = ""
+ badge_label: str = ""
+ cta_primary_label: str = ""
+ cta_primary_href: str = ""
+ cta_secondary_label: str = ""
+ cta_secondary_href: str = ""
+ scroll_label: str = ""
+
+
+class StoryIn(BaseModel):
+ title: str = ""
+ paragraphs: list[str] = []
+ quote: str = ""
+ quote_author: str = ""
+ stat1_value: str = ""
+ stat1_label: str = ""
+ stat2_value: str = ""
+ stat2_label: str = ""
+ link_label: str = ""
+ link_href: str = ""
+ bento: list[dict] = []
+
+
+class SectionHeadingIn(BaseModel):
+ key: str = Field(min_length=1, max_length=64)
+ label: str = ""
+ title: str = ""
+ lead: str = ""
+ image: str = ""
+ cta_label: str = ""
+ cta_href: str = ""
+
+
+class PageHeroIn(BaseModel):
+ key: str = Field(min_length=1, max_length=32)
+ title: str = ""
+ lead: str = ""
+ image: str = ""
+
+
+class InvestmentModelIn(BaseModel):
+ title: str = Field(min_length=1, max_length=256)
+ desc: str = ""
+ points: list[str] = []
+
+
+class ProcessStepIn(BaseModel):
+ step: str = ""
+ title: str = Field(min_length=1, max_length=256)
+ desc: str = ""
+
+
+class OfficeIn(BaseModel):
+ city: str = Field(min_length=1, max_length=64)
+ address: str = ""
+ hours: str = ""
+
+
+class GalleryImageIn(BaseModel):
+ src: str = ""
+ alt: str = ""
+
+
+class StatusLabelIn(BaseModel):
+ key: str = Field(min_length=1, max_length=16)
+ label: str = Field(min_length=1, max_length=64)
+
+
+class ContactSectionIn(BaseModel):
+ label: str = ""
+ title: str = ""
+ lead: str = ""
+ address_label: str = ""
+ phone_label: str = ""
+ email_label: str = ""
+ name_label: str = ""
+ name_placeholder: str = ""
+ phone_field_label: str = ""
+ phone_placeholder: str = ""
+ note_label: str = ""
+ note_optional_label: str = ""
+ note_placeholder: str = ""
+ submit_label: str = ""
+ sending_label: str = ""
+ success_title: str = ""
+ success_text: str = ""
+ success_button_label: str = ""
+
+
+class NewsletterSectionIn(BaseModel):
+ title: str = ""
+ lead: str = ""
+ placeholder: str = ""
+ button_label: str = ""
+ sending_label: str = ""
+ success_text: str = ""
+
+
+class FooterIn(BaseModel):
+ about_text: str = ""
+ pages_heading: str = ""
+ services_heading: str = ""
+ contact_heading: str = ""
+ copyright: str = ""
+ tagline: str = ""
+ socials: list[dict] = []
+
+
+class SeoMetaIn(BaseModel):
+ page_key: str = Field(min_length=1, max_length=32)
+ title: str = ""
+ description: str = ""
+
+
+class SeoMetaPatch(BaseModel):
+ page_key: Optional[str] = None
+ title: Optional[str] = None
+ description: 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
+ date: Optional[str] = None
+ category: Optional[str] = None
+ image: Optional[str] = None
+ read: Optional[str] = None
+ excerpt: Optional[str] = None
+ body: Optional[dict] = None
+ status: Optional[Literal["draft", "published"]] = None
+ seo_keyphrase: Optional[str] = None
+ _validate_body = field_validator("body")(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]
diff --git a/backend/app/seed.py b/backend/app/seed.py
new file mode 100644
index 0000000..07b091e
--- /dev/null
+++ b/backend/app/seed.py
@@ -0,0 +1,94 @@
+"""اسکریپت seed دیتابیس.
+
+اجرا:
+ python -m app.seed # فقط جدولهای خالی پر میشوند
+ python -m app.seed --force # حذف کامل دادهها و ساخت مجدد
+
+پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدولهای جدید یا خالی
+seed میشوند و دادههای قبلی دستنخورده میمانند.
+"""
+
+import argparse
+from typing import Any
+
+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, "سئوی صفحات"),
+]
+
+
+def seed(force: bool = False) -> None:
+ db = SessionLocal()
+ try:
+ if force:
+ Base.metadata.drop_all(bind=engine)
+ print("جداول قبلی حذف شدند.")
+
+ Base.metadata.create_all(bind=engine)
+ run_migrations()
+
+ for model, rows, label in CONTENT:
+ if db.query(model).count() > 0:
+ continue
+ for idx, row in enumerate(rows):
+ payload = dict(row)
+ if hasattr(model, "position") and "position" not in payload:
+ payload["position"] = idx
+ # بدنه مقالات در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
+ if model is models.Article and isinstance(payload.get("body"), list):
+ payload["body"] = paragraphs_to_doc(payload["body"])
+ db.add(model(**payload))
+
+ if db.query(models.AdminUser).count() == 0:
+ db.add(
+ models.AdminUser(
+ username=settings.admin_username,
+ password_hash=hash_password(settings.admin_password),
+ )
+ )
+ print(f"کاربر ادمین ساخته شد: {settings.admin_username}")
+
+ db.commit()
+
+ for model, _, label in CONTENT:
+ print(f" {label}: {db.query(model).count()} رکورد")
+ print("✔ seed با موفقیت انجام شد.")
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Seed کردن دیتابیس گروه ساختمانی آرامش")
+ parser.add_argument("--force", action="store_true", help="حذف همه جداول و ساخت مجدد")
+ args = parser.parse_args()
+ seed(force=args.force)
diff --git a/backend/app/seed_data.py b/backend/app/seed_data.py
new file mode 100644
index 0000000..803bf8d
--- /dev/null
+++ b/backend/app/seed_data.py
@@ -0,0 +1,458 @@
+"""دادههای اولیه سایت گروه ساختمانی آرامش — برگرفته از فایل src/data/site.ts فرانتاند."""
+
+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",
+ "phone": "۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰",
+ "phone_href": "tel:+982188742210",
+ "email": "info@arameshbuildinggroup.com",
+ "address": "تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم",
+}
+
+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",
+]
+
+SERVICES = [
+ {"title": "طراحی معماری", "desc": "خلق فرمهای معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشههای اجرایی و جزئیات نما.", "icon": "draft"},
+ {"title": "ساخت برجهای مسکونی", "desc": "اجرای برجهای بلندمرتبه با استانداردهای سازهای بینالمللی و کیفیت تحویل بینقص.", "icon": "tower"},
+ {"title": "مجتمعهای تجاری", "desc": "طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.", "icon": "mall"},
+ {"title": "ویلاسازی", "desc": "ویلاهای اختصاصی همنشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.", "icon": "villa"},
+ {"title": "بازسازی", "desc": "بازآفرینی ساختمانهای موجود با ارتقای سازه، نما و تجربه زندگی در فضا.", "icon": "renew"},
+ {"title": "مدیریت پروژه", "desc": "کنترل همزمان زمان، هزینه و کیفیت با گزارشدهی شفاف در تمام مراحل ساخت.", "icon": "manage"},
+ {"title": "سرمایهگذاری ساختمانی", "desc": "ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.", "icon": "invest"},
+ {"title": "محوطهسازی و لنداسکیپ", "desc": "طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.", "icon": "landscape"},
+]
+
+STATS = [
+ {"value": 21, "suffix": "+", "label": "سال تجربه ساخت"},
+ {"value": 47, "suffix": "", "label": "پروژه تحویلشده"},
+ {"value": 860, "suffix": "هزار", "label": "مترمربع زیربنا"},
+ {"value": 1200, "suffix": "+", "label": "خانواده ساکن"},
+]
+
+PROJECTS = [
+ {
+ "slug": "aramesh-elahieh",
+ "title": "برج آرامش الهیه",
+ "location": "تهران، الهیه",
+ "year": "۱۴۰۲",
+ "status": "done",
+ "image": IMAGES[0],
+ "gallery": [IMAGES[0], IMAGES[3], IMAGES[1]],
+ "area": "۲۴٬۰۰۰ متر مربع",
+ "floors": "۱۸ طبقه",
+ "units": "۵۶ واحد",
+ "client": "سرمایهگذاری خصوصی",
+ "summary": "یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوستهای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.",
+ "body": [
+ "برج آرامش الهیه روی زمینی با شیب طبیعی شمالی طراحی شد؛ همین شیب فرصت داد تا لابی، استخر و فضاهای مشترک در سه تراز مختلف بنشینند و همه واحدها دید بیواسطه به شهر داشته باشند.",
+ "پوسته بیرونی ترکیبی از سنگ تراورتن روشن و پنجرههای قدی با شیشه سهجداره است. سایهبانهای عمودی مسیرنگ، نور مستقیم تابستان را میشکنند و در زمستان اجازه ورود کامل نور را میدهند.",
+ "اجرای پروژه در سیودو ماه به پایان رسید و تمام واحدها پیش از تحویل کلید به فروش رسیدند.",
+ ],
+ },
+ {
+ "slug": "parseh-mall",
+ "title": "مجتمع تجاری پارسه",
+ "location": "اصفهان، مرکز شهر",
+ "year": "۱۴۰۱",
+ "status": "done",
+ "image": IMAGES[1],
+ "gallery": [IMAGES[1], IMAGES[2], IMAGES[4]],
+ "area": "۳۸٬۵۰۰ متر مربع",
+ "floors": "۷ طبقه",
+ "units": "۱۴۰ واحد تجاری",
+ "client": "گروه سرمایهگذاری پارسه",
+ "summary": "مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را بهصورت طبیعی در همه ترازها توزیع میکند.",
+ "body": [
+ "مطالعه رفتار مخاطب پیش از طراحی نشان داد کوتاهترین مسیر عمودی، کلید موفقیت تجاری این مجموعه است؛ بنابراین آتریوم مرکزی با پلهبرقیهای قطری در قلب پلان قرار گرفت.",
+ "نورگیر سقفی با شیشه لو-ای، روشنایی روز را تا تراز منفی دو میرساند و مصرف روشنایی مصنوعی را بهطور محسوس کاهش میدهد.",
+ "مجموعه دو ماه پیش از موعد قراردادی به بهرهبرداری رسید.",
+ ],
+ },
+ {
+ "slug": "daryakenar-villas",
+ "title": "ویلاهای دریاکنار",
+ "location": "مازندران، نوشهر",
+ "year": "۱۴۰۰",
+ "status": "done",
+ "image": IMAGES[2],
+ "gallery": [IMAGES[2], IMAGES[4], IMAGES[0]],
+ "area": "۹٬۲۰۰ متر مربع",
+ "floors": "۲ طبقه",
+ "units": "۱۲ ویلا",
+ "client": "مجموعه خصوصی دریاکنار",
+ "summary": "دوازده ویلای اختصاصی با چیدمان شانهای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم میکند.",
+ "body": [
+ "چیدمان شانهای ویلاها نتیجه دهها بررسی زاویه دید بود؛ هیچ ویلایی در دید ویلای دیگر نیست و همه به دریا باز میشوند.",
+ "مصالح، پاسخی به اقلیم مرطوب شمال است: چوب ترموود، بتن اکسپوز و پوششهای ضد نمک برای فلزات نما.",
+ "محوطه با گیاهان بومی طراحی شد تا نیاز آبیاری در طول سال حداقل باشد.",
+ ],
+ },
+ {
+ "slug": "niavaran-twins",
+ "title": "برج دوقلوی نیاوران",
+ "location": "تهران، نیاوران",
+ "year": "۱۳۹۹",
+ "status": "done",
+ "image": IMAGES[3],
+ "gallery": [IMAGES[3], IMAGES[0], IMAGES[2]],
+ "area": "۳۱٬۷۰۰ متر مربع",
+ "floors": "۲۰ طبقه",
+ "units": "۸۰ واحد",
+ "client": "مشارکت با مالکان زمین",
+ "summary": "دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگها را در یک تراز واحد ادغام میکند.",
+ "body": [
+ "دو برج روی یک پادست مشترک نشستهاند؛ این تصمیم اجازه داد فضاهای رفاهی، سالن اجتماعات و پارکینگها بین دو ساختمان مشترک شوند و سطح اشغال باغ کاهش یابد.",
+ "سازه با سیستم دیوار برشی و دال پسکشیده اجرا شد تا دهانههای داخلی آزاد و پلانها قابل شخصیسازی باشند.",
+ ],
+ },
+ {
+ "slug": "kish-garden-tower",
+ "title": "باغبرج کیش",
+ "location": "کیش، مروارید",
+ "year": "۱۴۰۵ (پیشبینی)",
+ "status": "progress",
+ "image": IMAGES[4],
+ "gallery": [IMAGES[4], IMAGES[1], IMAGES[3]],
+ "area": "۱۸٬۴۰۰ متر مربع",
+ "floors": "۱۴ طبقه",
+ "units": "۶۲ واحد اقامتی",
+ "client": "صندوق پروژهمحور آرامش",
+ "summary": "باغبرجی اقامتی با تراسهای سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.",
+ "body": [
+ "تراسهای سبز پیوسته، پوستهای زنده برای ساختمان میسازند که در اقلیم گرم جزیره، دمای سطح نما را بهطور محسوس پایین میآورد.",
+ "در حال حاضر سازه تا تراز طبقه نهم اجرا شده و نصب نما از ضلع جنوبی آغاز شده است.",
+ ],
+ },
+ {
+ "slug": "aramesh-business-center",
+ "title": "مرکز تجاری آرامش تهران",
+ "location": "تهران، سعادتآباد",
+ "year": "۱۴۰۶ (پیشبینی)",
+ "status": "progress",
+ "image": IMAGES[1],
+ "gallery": [IMAGES[1], IMAGES[3], IMAGES[4]],
+ "area": "۲۷٬۳۰۰ متر مربع",
+ "floors": "۱۲ طبقه",
+ "units": "۹۰ دفتر اداری",
+ "client": "مشارکت با هلدینگ آرامش",
+ "summary": "مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفتکاری.",
+ "body": [
+ "برنامهریزی فضایی این مجموعه بر اساس دفاتر انعطافپذیر انجام شد؛ هر تراز میتواند به دو، سه یا شش دفتر مستقل تقسیم شود.",
+ "اجرای تأسیسات مکانیکی بهصورت همزمان با سفتکاری پیش میرود تا زمان کل پروژه کوتاهتر شود.",
+ ],
+ },
+]
+
+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_label": "کانسپت اولیه",
+ "after_label": "پروژه تکمیلشده",
+}
+
+TIMELINE = [
+ {"year": "۱۳۸۳", "title": "آغاز مسیر", "desc": "تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.", "image": IMAGES[2]},
+ {"year": "۱۳۸۵", "title": "نخستین ساخت مستقل", "desc": "تحویل اولین ساختمان مسکونی هفتطبقه؛ آغاز همکاری با پیمانکاران سازهای بزرگ.", "image": IMAGES[0]},
+ {"year": "۱۳۸۷", "title": "ورود به بازار تجاری", "desc": "طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.", "image": IMAGES[1]},
+ {"year": "۱۳۸۹", "title": "استاندارد بینالمللی", "desc": "استقرار نظام کنترل کیفیت و دریافت گواهینامههای مدیریت پروژه.", "image": IMAGES[4]},
+ {"year": "۱۳۹۵", "title": "بلندمرتبهسازی", "desc": "آغاز ساخت نخستین برج بیستطبقه شرکت در شمال تهران.", "image": IMAGES[3]},
+ {"year": "۱۴۰۳", "title": "گروه ساختمانی آرامش امروز", "desc": "پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایهگذاری ساختمانی اختصاصی.", "image": IMAGES[0]},
+]
+
+WHY = [
+ {"title": "کیفیت بدون مصالحه", "desc": "انتخاب مصالح و مجریان بر اساس ماندگاری چنددههای، نه سریعترین تحویل."},
+ {"title": "شفافیت مالی کامل", "desc": "گزارش ماهانه هزینهها و پیشرفت فیزیکی برای همه شرکای پروژه."},
+ {"title": "تحویل بهموقع", "desc": "۹۴٪ پروژههای ما در بازه زمانی قراردادی یا زودتر تحویل داده شدهاند."},
+ {"title": "طراحی اختصاصی", "desc": "هیچ نقشهای دو بار اجرا نمیشود؛ هر پروژه هویت معماری خودش را دارد."},
+ {"title": "خدمات پس از تحویل", "desc": "پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید."},
+ {"title": "پایداری و انرژی", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد."},
+]
+
+TESTIMONIALS = [
+ {"name": "مهندس سارا کیانی", "role": "سرمایهگذار پروژه پارسه", "quote": "گزارشهای ماهانه گروه ساختمانی آرامش آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید."},
+ {"name": "دکتر امیرحسین راد", "role": "ساکن برج الهیه", "quote": "کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها."},
+ {"name": "مریم دلاور", "role": "مدیر مجموعه ویلاهای دریاکنار", "quote": "همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند."},
+]
+
+TEAM = [
+ {"name": "کامران آرامش", "role": "بنیانگذار و مدیرعامل", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه."},
+ {"name": "نگار شریفی", "role": "مدیر ارشد طراحی", "bio": "معمار ارشد، فارغالتحصیل دانشگاه تهران و میلان."},
+ {"name": "بهزاد مرادی", "role": "مدیر اجرایی پروژهها", "bio": "مسئول کنترل کیفیت و زمانبندی همه کارگاهها."},
+ {"name": "الهام نیکآیین", "role": "مدیر سرمایهگذاری", "bio": "طراح ساختارهای مشارکت و تحلیل بازدهی ملکی."},
+]
+
+ARTICLES = [
+ {
+ "slug": "natural-light",
+ "title": "معماری آرام: چرا نور طبیعی گرانترین مصالح ساختمان است؟",
+ "date": "۲۴ مرداد ۱۴۰۳",
+ "category": "معماری",
+ "image": IMAGES[1],
+ "read": "۶ دقیقه",
+ "excerpt": "نور طبیعی نهتنها کیفیت زندگی در فضا را تعیین میکند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر میگذارد.",
+ "body": [
+ "در بازار ساختوساز، ارزش یک واحد اغلب با متراژ سنجیده میشود؛ اما تجربه ما در بیست سال گذشته نشان داده کیفیت نور، تعیینکنندهتر از چند متر اضافه است.",
+ "جهتگیری ساختمان، عمق دهانه و ارتفاع کف تا سقف، سه متغیر اصلی نور طبیعی هستند. تصمیم درباره هر سه، پیش از ترسیم اولین پلان گرفته میشود و بعد از آن تقریباً برگشتپذیر نیست.",
+ "در برج آرامش الهیه، چرخش ۱۲ درجهای توده ساختمان نسبت به شمال، دو ساعت نور مفید بیشتر در روزهای زمستان به واحدها اضافه کرد؛ تصمیمی بیهزینه با اثری چنددههای.",
+ "برای انتخاب یک واحد، پیشنهاد ما ساده است: بازدید را در ساعتهای میانی صبح و اواخر بعدازظهر انجام دهید و کیفیت نور را در همان ساعتهایی بسنجید که واقعاً در خانه هستید.",
+ ],
+ },
+ {
+ "slug": "real-estate-portfolio",
+ "title": "راهنمای انتخاب سبد سرمایهگذاری ملکی در بازار امروز",
+ "date": "۱۰ مرداد ۱۴۰۳",
+ "category": "سرمایهگذاری",
+ "image": IMAGES[3],
+ "read": "۹ دقیقه",
+ "excerpt": "سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.",
+ "body": [
+ "سبد ملکی متعادل، ترکیبی از داراییهای با نقدشوندگی متفاوت است؛ نه همه سرمایه در یک پروژه بلندمدت، نه همه در واحدهای آماده.",
+ "افق زمانی، نخستین معیار است. اگر برنامه شما کمتر از دو سال است، پیشخرید در پروژهای که تازه شروع شده انتخاب پرریسکی خواهد بود.",
+ "معیار دوم شفافیت گزارشدهی است. بدون گزارش ماهانه پیشرفت فیزیکی و مالی، ارزیابی وضعیت سرمایه شما عملاً غیرممکن است.",
+ "معیار سوم، تنوع کاربری است. ترکیب مسکونی و تجاری، نوسان بازدهی سبد را در دورههای رکود محسوس کاهش میدهد.",
+ ],
+ },
+ {
+ "slug": "facade-quality",
+ "title": "پنج نکته فنی که کیفیت نما را پس از ده سال مشخص میکند",
+ "date": "۲۸ تیر ۱۴۰۳",
+ "category": "اجرا",
+ "image": IMAGES[4],
+ "read": "۵ دقیقه",
+ "excerpt": "نمای ساختمان در سال اول زیبا به نظر میرسد؛ تفاوت کیفیت اجرا در سال دهم آشکار میشود.",
+ "body": [
+ "اتصالات مکانیکی سنگ نما، مهمتر از خود سنگ است. اتصال چسبی در اقلیم سردوگرم ایران عمر کوتاهی دارد.",
+ "درزهای انقباضی باید در نقشه دیده شوند، نه در کارگاه تصمیمگیری شوند؛ نبودشان اولین دلیل ترکهای افقی نما است.",
+ "آببندی دور پنجره، پرتکرارترین نقطه شکست است. اجرای نوار آببند پیش از نصب قرنیز، این ریسک را حذف میکند.",
+ "زیرسازی گالوانیزه گرم، در برابر زیرسازی رنگشده، تفاوت قیمت اندکی دارد و تفاوت عمر بسیار زیادی.",
+ "در پایان، برنامه بازدید دورهای سالانه نما را جدی بگیرید؛ ترمیم یک درز، هزینهای در حد هیچ در برابر بازسازی کل نما است.",
+ ],
+ },
+ {
+ "slug": "commercial-yield",
+ "title": "چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟",
+ "date": "۱۵ تیر ۱۴۰۳",
+ "category": "سرمایهگذاری",
+ "image": IMAGES[2],
+ "read": "۷ دقیقه",
+ "excerpt": "مدل سادهای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.",
+ "body": [
+ "برآورد بازدهی تجاری با سه عدد آغاز میشود: جریان روزانه مخاطب، نرخ اجاره هر مترمربع در منطقه و نسبت سطح مفید به کل.",
+ "خطای رایج، نادیدهگرفتن ترازهای بالاتر است. بدون طراحی درست مسیر عمودی، ترازهای سه به بالا معمولاً نیمی از نرخ تراز همکف را هم بهدست نمیآورند.",
+ "در نهایت، سناریوی محافظهکارانه را هم بنویسید؛ اگر پروژه در سناریوی بدبینانه هم توجیه داشت، سرمایهگذاری منطقی است.",
+ ],
+ },
+ {
+ "slug": "facade-materials",
+ "title": "مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران",
+ "date": "۲ تیر ۱۴۰۳",
+ "category": "اجرا",
+ "image": IMAGES[0],
+ "read": "۸ دقیقه",
+ "excerpt": "هر مصالح نما در اقلیم متفاوتی درست کار میکند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر میکند.",
+ "body": [
+ "سنگ در اقلیم خشک، بهترین ماندگاری را دارد؛ اما وزن آن نیازمند زیرسازی دقیق و محاسبه سازهای است.",
+ "کامپوزیت انتخاب سریع و سبکی است، مشروط بر انتخاب گرید ضد آتش و درنظرگرفتن انقباض حرارتی در طول ورق.",
+ "ترموود در اقلیم مرطوب شمال، بهترین پاسخ زیباییشناسانه است، ولی برنامه نگهداری دوسالانه دارد.",
+ ],
+ },
+ {
+ "slug": "renovate-or-rebuild",
+ "title": "بازسازی یا نوسازی؟ معیارهای تصمیمگیری برای مالکان",
+ "date": "۲۰ خرداد ۱۴۰۳",
+ "category": "معماری",
+ "image": IMAGES[1],
+ "read": "۶ دقیقه",
+ "excerpt": "اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی میتواند بازدهی بالاتری از تخریب کامل داشته باشد.",
+ "body": [
+ "نخست وضعیت سازه ارزیابی میشود؛ اگر مقاومسازی هزینهای بیش از ۴۰ درصد ساخت نو دارد، نوسازی منطقیتر است.",
+ "دومین معیار، ضوابط شهری روز است. گاهی تخریب، سطح زیربنای مجاز کمتری به شما میدهد و این تصمیم را کاملاً تغییر میدهد.",
+ "در بازسازی، بیشترین ارزش افزوده از سه جا میآید: نما، ورودی و بازآرایی پلان داخلی.",
+ ],
+ },
+]
+
+FAQS = [
+ {"question": "حداقل سرمایه برای مشارکت در پروژههای گروه ساختمانی آرامش چقدر است؟", "answer": "ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایهگذاری از سطح مشخصی آغاز میشوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه میشود."},
+ {"question": "مدت زمان معمول ساخت یک برج مسکونی چقدر است؟", "answer": "بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است."},
+ {"question": "آیا امکان تغییر نقشه داخلی واحد وجود دارد؟", "answer": "بله. تا پیش از پایان مرحله سفتکاری، امکان شخصیسازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است."},
+ {"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"]
+]
diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py
new file mode 100644
index 0000000..6df8cea
--- /dev/null
+++ b/backend/app/seo_pages.py
@@ -0,0 +1,239 @@
+"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریقشده.
+
+فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
+این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
+title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق میکند —
+بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
+"""
+
+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"]
+
+
+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 _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,
+ og_image: str = "",
+ og_type: str = "website",
+ jsonld: dict | None = None,
+ noscript: str = "",
+) -> HTMLResponse:
+ """index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
+ title = title.strip() or "گروه ساختمانی آرامش"
+ description = description.strip()
+ canonical = _absolute(path)
+ image = _absolute(og_image)
+ esc = html.escape
+
+ # تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
+ tags = [
+ f'',
+ f'',
+ f'',
+ f'',
+ f'',
+ f'',
+ ]
+ if image:
+ tags.append(f'')
+ if jsonld:
+ payload = json.dumps(jsonld, ensure_ascii=False).replace("", "<\\/")
+ tags.append(f'')
+ block = "\n ".join(tags)
+
+ raw = _read_index()
+ if raw is None:
+ # بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
+ body = f"{esc(title)}
{esc(description)}
"
+ return HTMLResponse(
+ f''
+ f"{esc(title)}{block}{body}"
+ )
+
+ raw = re.sub(r".*?", f"{esc(title)}", raw, count=1, flags=re.S)
+ raw = raw.replace("", f" {block}\n ", 1)
+ if noscript:
+ raw = raw.replace("
"
+ return _shell(
+ title=f"{article.title} | {brand}",
+ description=article.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)
+ return _shell(
+ title=f"{project.title} | {brand}",
+ description=project.summary,
+ path=f"/projects/{project.slug}",
+ og_image=project.image,
+ jsonld={
+ "@context": "https://schema.org",
+ "@type": "WebPage",
+ "name": project.title,
+ "description": project.summary,
+ "image": _absolute(project.image),
+ "inLanguage": "fa",
+ },
+ )
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..80e9d4a
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1,4 @@
+# وابستگیهای توسعه و تست — pip install -r requirements-dev.txt
+-r requirements.txt
+pytest==8.3.4
+httpx==0.27.2
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..bc23690
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,13 @@
+# نسخهها دقیق پین شدهاند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
+# ارتقا: نسخه را دستی بالا ببرید و روی محیط آمادهسازی تست کنید.
+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
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..7277948
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,93 @@
+"""پیکربندی تستها — دیتابیس 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(
+ "