feat: update UI to glassmorphic design and copper theme

This commit is contained in:
Behrad
2026-09-08 01:40:00 +03:30
commit 1e68a84879
100 changed files with 15553 additions and 0 deletions
+23
View File
@@ -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
+31
View File
@@ -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/
+192
View File
@@ -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 تزریق‌شده سئو هم افشا نمی‌شوند
+29
View File
@@ -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
+16
View File
@@ -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"]
+189
View File
@@ -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 <access_token>
```
> نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.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 # ورود و پنل مدیریت
```
View File
View File
+354
View File
@@ -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()
+42
View File
@@ -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
+149
View File
@@ -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(),
}
+52
View File
@@ -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
+36
View File
@@ -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()
+20
View File
@@ -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()
+47
View File
@@ -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",
}
+71
View File
@@ -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()
+375
View File
@@ -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)
+49
View File
@@ -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)
+588
View File
@@ -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]
+94
View File
@@ -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)
+458
View File
@@ -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"]
]
+239
View File
@@ -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'<meta name="description" content="{esc(description)}" />',
f'<meta property="og:title" content="{esc(title)}" />',
f'<meta property="og:description" content="{esc(description)}" />',
f'<meta property="og:type" content="{esc(og_type)}" />',
f'<meta property="og:url" content="{esc(canonical)}" />',
f'<meta name="twitter:card" content="summary_large_image" />',
]
if image:
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
if jsonld:
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
tags.append(f'<script type="application/ld+json">{payload}</script>')
block = "\n ".join(tags)
raw = _read_index()
if raw is None:
# بیلد فرانت‌اند سوار نشده — صفحه حداقلی با خودِ متا تا خزنده‌ها دست خالی نمانند
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
return HTMLResponse(
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
)
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
raw = raw.replace("</head>", f" {block}\n </head>", 1)
if noscript:
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
return HTMLResponse(raw)
def _brand_name(db: Session) -> str:
brand = db.query(models.Brand).first()
return brand.name if brand else "گروه ساختمانی آرامش"
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
# ---------- robots.txt و sitemap.xml ----------
@router.get("/robots.txt", response_class=Response)
def robots(db: Session = Depends(get_db)):
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
if settings.site_url:
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
@router.get("/sitemap.xml", response_class=Response)
def sitemap(db: Session = Depends(get_db)):
urls = list(STATIC_PATHS)
urls += [
f"/blog/{a.slug}"
for a in db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
]
urls += [
f"/projects/{p.slug}"
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
]
items = "\n".join(
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{items}\n</urlset>\n"
)
return Response(content=xml, media_type="application/xml; charset=utf-8")
# ---------- صفحات وبلاگ ----------
@router.get("/blog", response_class=HTMLResponse)
def blog_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "blog")
return _shell(
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
description=meta.description if meta else "",
path="/blog",
og_image=meta.image if meta else "",
)
@router.get("/blog/{slug}", response_class=HTMLResponse)
def blog_detail(slug: str, db: Session = Depends(get_db)):
article = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None:
# مقاله ناموجود یا پیش‌نویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان می‌دهد
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
brand = _brand_name(db)
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
jsonld = {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.title,
"description": article.excerpt,
"image": _absolute(article.image),
"inLanguage": "fa",
"author": {"@type": "Organization", "name": brand},
"publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
}
noscript = f"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
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",
},
)
+4
View File
@@ -0,0 +1,4 @@
# وابستگی‌های توسعه و تست — pip install -r requirements-dev.txt
-r requirements.txt
pytest==8.3.4
httpx==0.27.2
+13
View File
@@ -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
+93
View File
@@ -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(
"<!doctype html><html><head><title>Shell</title></head>"
"<body><div id=\"root\"></div></body></html>",
encoding="utf-8",
)
settings.frontend_dist = str(dist)
settings.site_url = "https://test.example"
db = SessionLocal()
try:
db.add(models.AdminUser(username="admin", password_hash=hash_password("test-admin-pass-1")))
db.add(models.Brand(name="گروه ساختمانی آرامش"))
db.add(
models.Article(
slug="published-post", title="مقاله منتشرشده", status="published",
excerpt="چکیده مقاله", body=DOC, position=1,
)
)
db.add(
models.Article(
slug="draft-post", title="پیش‌نویس", status="draft",
excerpt="چکیده پیش‌نویس", body=DOC, position=2,
)
)
db.commit()
finally:
db.close()
yield
db.close()
engine.dispose()
if TEST_DB.exists():
TEST_DB.unlink()
@pytest.fixture(autouse=True)
def reset_rate_limiter():
from app import rate_limit
rate_limit._failed.clear()
yield
rate_limit._failed.clear()
@pytest.fixture()
def client():
return TestClient(app)
@pytest.fixture()
def auth_client(client):
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
assert res.status_code == 200, res.text
token = res.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
client.headers.pop("Authorization", None)
+182
View File
@@ -0,0 +1,182 @@
"""تست‌های API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیش‌نویس مقاله و سئو."""
import re
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
# ---------- عمومی ----------
def test_health(client):
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["status"] == "ok"
def test_site_hides_draft_articles(client):
res = client.get("/api/site")
assert res.status_code == 200
slugs = [a["slug"] for a in res.json()["articles"]]
assert "published-post" in slugs
assert "draft-post" not in slugs
def test_public_articles_hide_drafts(client):
res = client.get("/api/articles")
assert all(a["status"] == "published" for a in res.json())
assert client.get("/api/articles/draft-post").status_code == 404
def test_admin_endpoints_reject_anonymous(client):
assert client.get("/api/admin/articles").status_code == 401
# ---------- احراز هویت ----------
def test_login_success_and_failure(client):
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}).status_code
== 401
)
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
assert res.status_code == 200
assert res.json()["access_token"]
def test_change_password(auth_client, client):
res = auth_client.post(
"/api/auth/change-password",
json={"current_password": "wrong", "new_password": "brand-new-pass-9"},
)
assert res.status_code == 400
res = auth_client.post(
"/api/auth/change-password",
json={"current_password": "test-admin-pass-1", "new_password": "brand-new-pass-9"},
)
assert res.status_code == 200
# رمز قدیمی رد و رمز جدید قبول می‌شود
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}).status_code
== 401
)
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}).status_code
== 200
)
# بازگرداندن رمز برای تست‌های بعدی
res = client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"})
auth = {"Authorization": f"Bearer {res.json()['access_token']}"}
res = client.post(
"/api/auth/change-password",
json={"current_password": "brand-new-pass-9", "new_password": "test-admin-pass-1"},
headers=auth,
)
assert res.status_code == 200
def test_login_rate_limited(client):
for _ in range(5):
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 429
assert "Retry-After" in res.headers
# ---------- CRUD مدیریت ----------
def test_article_crud_and_draft_flow(auth_client, client):
created = auth_client.post(
"/api/admin/articles",
json={"slug": "flow-post", "title": "مقاله جریان", "body": DOC},
)
assert created.status_code == 201, created.text
article = created.json()
# مقاله تازه پیش‌نویس است و در سایت عمومی دیده نمی‌شود
assert article["status"] == "draft"
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"seo_keyphrase": "ساختمان"})
assert res.json()["seo_keyphrase"] == "ساختمان"
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"status": "published"})
assert res.status_code == 200
assert "flow-post" in [a["slug"] for a in client.get("/api/articles").json()]
assert auth_client.delete(f"/api/admin/articles/{article['id']}").status_code == 204
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
def test_service_roundtrip_and_reorder(auth_client):
ids = []
for name in ("خدمت الف", "خدمت ب"):
res = auth_client.post("/api/admin/services", json={"title": name})
assert res.status_code == 201
ids.append(res.json()["id"])
res = auth_client.post("/api/admin/services/reorder", json={"ids": ids[::-1]})
assert res.status_code == 200
ordered = [s["id"] for s in res.json() if s["id"] in ids]
assert ordered == ids[::-1]
res = auth_client.patch(f"/api/admin/services/{ids[0]}", json={"desc": "توضیح تازه"})
assert res.json()["desc"] == "توضیح تازه"
for i in ids:
assert auth_client.delete(f"/api/admin/services/{i}").status_code == 204
def test_seo_meta_create_and_patch(auth_client):
res = auth_client.post(
"/api/admin/seo-meta", json={"page_key": "test-page", "title": "عنوان", "description": "توضیح"}
)
assert res.status_code == 201
meta_id = res.json()["id"]
res = auth_client.patch(f"/api/admin/seo-meta/{meta_id}", json={"title": "عنوان تازه"})
assert res.json()["title"] == "عنوان تازه"
assert auth_client.delete(f"/api/admin/seo-meta/{meta_id}").status_code == 204
# ---------- سئو ----------
def test_robots_txt(client):
res = client.get("/robots.txt")
assert res.status_code == 200
assert "Disallow: /admin" in res.text
assert "Sitemap: https://test.example/sitemap.xml" in res.text
def test_sitemap_includes_published_only(client):
res = client.get("/sitemap.xml")
assert res.status_code == 200
assert "/blog/published-post" in res.text
assert "/blog/draft-post" not in res.text
assert res.text.startswith("<?xml")
def test_head_injection_for_article(client):
res = client.get("/blog/published-post")
assert res.status_code == 200
assert "مقاله منتشرشده" in res.text
assert 'property="og:title"' in res.text
assert "application/ld+json" in res.text
assert "https://test.example/blog/published-post" in res.text
def test_head_not_injected_for_draft(client):
res = client.get("/blog/draft-post")
assert res.status_code == 200
assert "پیش‌نویس" not in res.text # عنوان پیش‌نویس افشا نمی‌شود
assert "application/ld+json" not in res.text
def test_head_replaces_title_once(client):
res = client.get("/blog/published-post")
assert len(re.findall(r"<title>", res.text)) == 1
+57
View File
@@ -0,0 +1,57 @@
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۴۳.
# پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور.
#
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند.
services:
traefik:
command: !override
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL را در .env تنظیم کنید}
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
ports: !override
- "80:80"
- "443:443"
volumes: !override
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt
backend:
environment:
# سایت هم‌مبدأ است — CORS لازم نیست
CORS_ORIGINS: '[]'
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
- traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le
minio:
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=websecure
- traefik.http.routers.media.tls.certresolver=le
frontend:
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls.certresolver=le
volumes:
letsencrypt_data:
+158
View File
@@ -0,0 +1,158 @@
# مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید:
# cp .env.example .env
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
services:
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO)
traefik:
image: traefik:v3
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
ports:
# پورت به 8080 تغییر کرد تا با پورت 5173 سرور لوکال Vite تداخل نکند
- "${TRAEFIK_HTTP_PORT:-8080}:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
POSTGRES_DB: arameshplaza
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
interval: 5s
timeout: 5s
retries: 12
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 12
ports:
- "9001:9001" # کنسول MinIO
- "9000:9000" # دسترسی لوکال به تصاویر و مدیا
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=web
- traefik.http.routers.media.priority=100
- traefik.http.routers.media.middlewares=media-rewrite
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
- traefik.http.services.media.loadbalancer.server.port=9000
# یک‌بار اجرا می‌شود: ساخت باکت و بازگذاشتن خواندن عمومی برای سرو تصاویر
minio-setup:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
MINIO_BUCKET: aramesh-media
restart: "no"
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing local/$$MINIO_BUCKET &&
mc anonymous set download local/$$MINIO_BUCKET &&
echo 'minio-setup: bucket ready'
"
backend:
build: ./backend
restart: unless-stopped
ports:
- "8000:8000" # اتصال مستقیم بک‌اند به ویندوز برای فرانت‌اند لوکال
environment:
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
CORS_ORIGINS: ${CORS_ORIGINS:-'["http://localhost:5173","http://127.0.0.1:5173"]'}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
S3_BUCKET: aramesh-media
MEDIA_PUBLIC_BASE: /media
# نشانی عمومی سایت — برای sitemap و آدرس مطلق OG (مثل https://aramesh.example)
SITE_URL: ${SITE_URL:-}
# بیلد فرانت‌اند برای تزریق متای سئو در صفحات /blog و /projects
FRONTEND_DIST: /srv/frontend
volumes:
- ./frontend/dist:/srv/frontend:ro
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=web
- traefik.http.routers.api.priority=100
- traefik.http.services.api.loadbalancer.server.port=8000
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
- traefik.http.routers.seo.entrypoints=web
- traefik.http.routers.seo.priority=50
- traefik.http.routers.seo.service=api
# فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
- backend
- minio
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 15s
timeout: 5s
retries: 5
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=web
- traefik.http.routers.web.priority=1
- traefik.http.services.web.loadbalancer.server.port=80
# ابزار پشتیبان‌گیری — فقط با پروفایل tools اجرا می‌شود (scripts/backup.sh این کار را می‌کند)
mc:
image: minio/mc:latest
profiles: ["tools"]
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes:
db_data:
minio_data:
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+12
View File
@@ -0,0 +1,12 @@
:80 {
root * /srv
encode gzip
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمی‌گردند
try_files {path} /index.html
file_server
# کش بلندمدت برای فایل‌های هش‌دار بیلد
@assets path /assets/*
header @assets Cache-Control "public, immutable, max-age=31536000"
}
+10
View File
@@ -0,0 +1,10 @@
# ---------- فرانت‌اند فقط سرو می‌کند ----------
# بیلد روی هاست انجام می‌شود (npm install && npm run build) و خروجی dist/
# داخل ایمیج کپی می‌شود — شبکه کانتینر بیلد برای npm قابل‌اعتماد نیست.
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY dist /srv
EXPOSE 80
+33
View File
@@ -0,0 +1,33 @@
# فرانت‌اند گروه ساختمانی آرامش
سایت React + Vite + Tailwind — تمام متن‌ها و تصاویر از بک‌اند (`GET /api/site`) خوانده می‌شود.
## اجرا
```bash
npm install
npm run dev
```
در حالت توسعه، Vite درخواست‌های `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی می‌کند —
پس استک Docker باید در حال اجرا باشد. آدرس بک‌اند در `src/config.ts` از `VITE_API_URL`
خوانده می‌شود؛ پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ.
## ساخت
بیلد روی هاست انجام می‌شود (کانتینر فقط با Caddy فایل‌های `dist/` را سرو می‌کند):
```bash
npm run build
```
> نکته: بک‌اند برای تزریق متای سئو در صفحات `/blog` و `/projects` همین پوشه `dist/` را
> (در compose به `/srv/frontend` سوار است) می‌خواند — بعد از هر تغییر فرانت، `npm run build`
> و سپس ری‌استارت بک‌اند کافی است.
## بخش‌های کلیدی
- `src/api.tsx` — بارگذاری داده سایت و ادغام پیش‌نویس مقاله‌ها برای ادمین
- `src/admin/` — لایه ویرایش زنده (دوقلوی دیجیتال): نوار مدیریت، کشوی موجودیت‌ها، صفحه نوشتن مقاله
- `src/seo.ts` — تگ‌های title / description / OG / canonical / JSON-LD هر صفحه
- `src/data/site.ts` — انواع داده مشترک
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>گروه ساختمانی آرامش</title>
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژه‌های ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایه‌گذاری ساخت‌وساز." />
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+5203
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "magic-patterns-vite-template",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "npx vite",
"build": "npx vite build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"preview": "npx vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
"@types/node": "^20.11.18",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "latest",
"eslint": "^8.50.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"postcss": "latest",
"tailwindcss": "3.4.17",
"typescript": "^5.5.4",
"vite": "^5.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+140
View File
@@ -0,0 +1,140 @@
import React, { useEffect } from 'react';
import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { Preloader } from './components/Preloader';
import { Header } from './components/Header';
import { Footer } from './components/Footer';
import { FloatingContact } from './components/FloatingContact';
import { Home } from './pages/Home';
import { About } from './pages/About';
import { ServicesPage } from './pages/ServicesPage';
import { Projects } from './pages/Projects';
import { ProjectDetail } from './pages/ProjectDetail';
import { Investment } from './pages/Investment';
import { Blog } from './pages/Blog';
import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage';
import { SiteProvider, useSiteData } from './api';
import { applySeo, seoKeyForPath } from './seo';
import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage';
import { ArticleWritePage } from './admin/ArticleWritePage';
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'auto' });
}, [pathname]);
return null;
}
function AnimatedRoutes() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<motion.main
key={location.pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
<Routes location={location}>
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:slug" element={<ProjectDetail />} />
<Route path="/investment" element={<Investment />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:slug" element={<ArticleDetail />} />
<Route path="/faq" element={<FaqPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="*" element={<Home />} />
</Routes>
</motion.main>
</AnimatePresence>);
}
function AppChrome() {
const { pathname } = useLocation();
const { isAuthenticated } = useAdmin();
const { data } = useSiteData();
const isAdminPage = pathname.startsWith('/admin');
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند
useEffect(() => {
if (isAdminPage || !data) return;
const key = seoKeyForPath(pathname);
if (!key) return;
const meta = data.seo_meta?.find((s) => s.page_key === key);
const brand = data.brand.name;
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
applySeo({
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
description: meta?.description ?? '',
path: pathname,
});
}, [pathname, data, isAdminPage]);
return (
<>
{!isAdminPage && <Header />}
<AnimatedRoutes />
{!isAdminPage && <Footer />}
{!isAdminPage && <FloatingContact />}
{isAuthenticated && !isAdminPage && <AdminBar />}
</>
);
}
function AppShell() {
const { data, error, reload } = useSiteData();
return (
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
<Preloader />
{error ? (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
</p>
<button
type="button"
onClick={reload}
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
>
تلاش دوباره
</button>
</div>
) : data ? (
<BrowserRouter>
<ScrollToTop />
<AppChrome />
</BrowserRouter>
) : null}
</div>
);
}
export function App() {
useEffect(() => {
document.documentElement.setAttribute('dir', 'rtl');
document.documentElement.setAttribute('lang', 'fa');
}, []);
return (
<SiteProvider>
<AdminProvider>
<AppShell />
</AdminProvider>
</SiteProvider>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useState } from 'react'
import Swal from 'sweetalert2'
import { KeyRoundIcon, XIcon } from 'lucide-react'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
const STATUS_TEXT: Record<string, string> = {
idle: 'همگام‌سازی شد',
saving: 'در حال ذخیره…',
error: 'خطا در ذخیره',
}
const STATUS_COLOR: Record<string, string> = {
idle: 'bg-emerald-400',
saving: 'bg-copper animate-pulse',
error: 'bg-red-400',
}
const SWAL_BASE = {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
}
function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
if (next.length < 8) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دست‌کم ۸ نویسه باشد.', confirmButtonText: 'باشه' })
return
}
if (next !== confirm) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' })
return
}
setBusy(true)
try {
await adminApi.changePassword(current, next)
void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' })
onClose()
} catch (error) {
void Swal.fire({
...SWAL_BASE,
icon: 'error',
title: 'تغییر رمز ناموفق بود',
text: error instanceof Error ? error.message : 'دوباره تلاش کنید',
confirmButtonText: 'باشه',
})
} finally {
setBusy(false)
}
}
const field =
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none'
return (
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="تغییر رمز عبور">
<div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
<KeyRoundIcon className="h-4 w-4" />
تغییر رمز عبور
</h2>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="text-ivory/50 transition-colors hover:text-ivory"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-6 grid gap-4">
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder="رمز عبور فعلی"
aria-label="رمز عبور فعلی"
autoComplete="current-password"
className={field}
/>
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder="رمز جدید (دست‌کم ۸ نویسه)"
aria-label="رمز جدید"
autoComplete="new-password"
className={field}
/>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="تکرار رمز جدید"
aria-label="تکرار رمز جدید"
autoComplete="new-password"
className={field}
/>
</div>
<button
type="button"
onClick={() => void submit()}
disabled={busy || !current || !next}
className="mt-6 w-full bg-copper py-2.5 text-sm font-bold text-char transition-opacity disabled:opacity-50"
>
{busy ? 'در حال ذخیره…' : 'ذخیره رمز جدید'}
</button>
</div>
</div>
)
}
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
const [pwOpen, setPwOpen] = useState(false)
return (
<>
<div
dir="rtl"
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
>
<span className="flex items-center gap-2 text-xs font-bold text-copper">
<span className="flex h-2 w-2 rounded-full bg-copper" />
حالت مدیریت
</span>
<button
type="button"
role="switch"
aria-checked={isEditMode}
onClick={toggleEditMode}
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
>
<span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
}`}
>
<span
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
}`}
/>
</span>
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
</button>
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
{STATUS_TEXT[saveStatus]}
</span>
<button
type="button"
onClick={() => setPwOpen(true)}
className="flex items-center gap-1.5 border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
<KeyRoundIcon className="h-3.5 w-3.5" />
رمز
</button>
<button
type="button"
onClick={logout}
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
خروج
</button>
</div>
{pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
</>
)
}
+162
View File
@@ -0,0 +1,162 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import Swal from 'sweetalert2'
import type { SiteData } from '../api'
import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient'
export type SaveStatus = 'idle' | 'saving' | 'error'
interface AdminContextValue {
isAuthenticated: boolean
isEditMode: boolean
toggleEditMode: () => void
login: (username: string, password: string) => Promise<void>
logout: () => void
saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
runSave: (action: () => Promise<void>) => Promise<boolean>
}
const AdminContext = createContext<AdminContextValue | null>(null)
export function useAdmin(): AdminContextValue {
const ctx = useContext(AdminContext)
if (!ctx) throw new Error('useAdmin باید داخل AdminProvider استفاده شود')
return ctx
}
function showSaveError(error: unknown): void {
const message =
error instanceof HttpError ? error.message : 'ارتباط با سرور برقرار نشد — اتصال اینترنت را بررسی کنید'
Swal.fire({
icon: 'error',
title: 'ذخیره نشد',
text: message,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'باشه',
timer: 4000,
timerProgressBar: true,
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
})
}
export function AdminProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
const [isEditMode, setIsEditMode] = useState<boolean>(
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const { reload } = useSiteData()
const toggleEditMode = useCallback(() => {
setIsEditMode((v) => {
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
return !v
})
}, [])
const login = useCallback(async (username: string, password: string) => {
await adminApi.login(username, password)
setIsAuthenticated(true)
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند
reload()
}, [reload])
const logout = useCallback(() => {
setToken(null)
setIsAuthenticated(false)
setIsEditMode(false)
sessionStorage.removeItem('aramesh_edit_mode')
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند
reload()
}, [reload])
const runSave = useCallback(
async (action: () => Promise<void>): Promise<boolean> => {
setSaveStatus('saving')
try {
await action()
setSaveStatus('idle')
return true
} catch (error) {
setSaveStatus('error')
showSaveError(error)
if (error instanceof HttpError && error.status === 401) {
logout()
reload()
}
return false
}
},
[logout, reload],
)
// در حالت ویرایش، کلیک روی لینک‌ها مسیریابی نمی‌کند تا ویرایش متن ممکن باشد
useEffect(() => {
if (!isEditMode) return
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement | null)?.closest('a')
// لینک‌های خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار می‌کنند
if (anchor && !anchor.dataset.adminLink) e.preventDefault()
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
}, [isEditMode])
// در حین ذخیره‌سازی، از بسته‌شدن تصادفی صفحه جلوگیری می‌شود
useEffect(() => {
if (saveStatus !== 'saving') return
const guard = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
window.addEventListener('beforeunload', guard)
return () => window.removeEventListener('beforeunload', guard)
}, [saveStatus])
const value = useMemo<AdminContextValue>(
() => ({
isAuthenticated,
isEditMode,
toggleEditMode,
login,
logout,
saveStatus,
runSave,
}),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
)
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
}
/**
* پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت
* همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند.
*/
export function useFieldSaver() {
const { runSave } = useAdmin()
const { updateSite } = useSiteData()
return useCallback(
(
resource: string,
id: number,
changes: Record<string, unknown>,
apply: (data: SiteData) => void,
): Promise<boolean> =>
runSave(async () => {
await adminApi.patch(resource, id, changes)
updateSite(apply)
}),
[runSave, updateSite],
)
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useAdmin } from './AdminContext'
/** صفحه ورود ادمین — مسیر پنهان /admin/login */
export function AdminLoginPage() {
const { isAuthenticated, login } = useAdmin()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
if (isAuthenticated) return <Navigate to="/" replace />
const submit = async (e: FormEvent) => {
e.preventDefault()
if (submitting) return
setError('')
setSubmitting(true)
try {
await login(username.trim(), password)
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
} finally {
setSubmitting(false)
}
}
return (
<section className="flex min-h-screen flex-col items-center justify-center bg-ink px-6" dir="rtl">
<div className="w-full max-w-sm border border-ivory/10 bg-char p-10">
<h1 className="text-xl font-bold text-ivory">ورود مدیریت</h1>
<p className="mt-3 text-sm font-light leading-7 text-ivory/55">
برای فعال‌سازی حالت ویرایش زنده سایت وارد شوید.
</p>
<form onSubmit={submit} className="mt-8 grid gap-5" noValidate>
<div>
<label htmlFor="admin-username" className="block text-sm font-bold text-ivory/90">
نام کاربری
</label>
<input
id="admin-username"
type="text"
dir="ltr"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="admin-password" className="block text-sm font-bold text-ivory/90">
رمز عبور
</label>
<input
id="admin-password"
type="password"
dir="ltr"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
/>
</div>
{error && (
<p className="border-r-2 border-red-400/70 pr-3 text-xs font-light leading-6 text-red-300">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="mt-2 bg-copper px-8 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-[1.02] disabled:opacity-50"
>
{submitting ? 'در حال ورود…' : 'ورود'}
</button>
</form>
</div>
</section>
)
}
+375
View File
@@ -0,0 +1,375 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField'
import type { ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
type Draft = {
id: number
slug: string
title: string
date: string
category: string
image: string
read: string
excerpt: string
body: ArticleBodyDoc
status: 'draft' | 'published'
seo_keyphrase: string
}
const STATUS_TEXT: Record<string, string> = {
idle: 'ذخیره شد',
saving: 'در حال ذخیره…',
error: 'خطا در ذخیره',
}
/**
* صفحه تمام‌عرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
*/
export function ArticleWritePage() {
const { isAuthenticated, saveStatus, runSave } = useAdmin()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data, updateSite } = useSiteData()
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
const [fetched, setFetched] = useState<Draft | null>(null)
// مقاله تازه همیشه پیش‌نویس شروع می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود
const [draft, setDraft] = useState<Draft>({
id: 0,
slug: `article-${Date.now()}`,
title: '',
date: new Date().toLocaleDateString('fa-IR'),
category: 'یادداشت',
image: '',
read: '۵ دقیقه',
excerpt: '',
body: EMPTY_DOC,
status: 'draft',
seo_keyphrase: '',
})
const [createdId, setCreatedId] = useState<number | null>(null)
const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft)
draftRef.current = draft
const createdRef = useRef(createdId)
createdRef.current = createdId
const pendingRef = useRef<Record<string, unknown> | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیش‌نویس‌ها) مستقیم از انتهای مدیریتی خوانده می‌شود
useEffect(() => {
if (!id || existing) return
let cancelled = false
adminApi
.get<Draft>('articles', Number(id))
.then((a) => {
if (!cancelled) setFetched(a)
})
.catch(() => {
/* مقاله وجود ندارد — فرم «مقاله جدید» می‌ماند */
})
return () => {
cancelled = true
}
}, [id, existing])
const article = existing ?? fetched
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => {
if (!article || createdRef.current) return
setDraft({
...article,
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
})
setCreatedId(article.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [article?.id])
const doSave = async (changes: Record<string, unknown>) => {
if (createdRef.current) {
await adminApi.patch('articles', createdRef.current, changes)
updateSite((d) => {
const a = d.articles.find((x) => x.id === createdRef.current)
if (a) Object.assign(a, changes)
})
} else {
// اولین ذخیره = ساخت پیش‌نویس: مقاله ساخته می‌شود اما تا «انتشار» در سایت عمومی دیده نمی‌شود
const payload = { ...draftRef.current, ...changes }
if (!payload.title) payload.title = 'بدون عنوان'
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
setCreatedId(created.id)
updateSite((d) => {
d.articles.unshift(created as never)
})
navigate(`/admin/write/${created.id}`, { replace: true })
}
}
const flush = useCallback(async () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
const changes = pendingRef.current
if (!changes) return
pendingRef.current = null
await doSave(changes)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const queueSave = (changes: Record<string, unknown>) => {
pendingRef.current = { ...(pendingRef.current ?? {}), ...changes }
setDraft((d) => ({ ...d, ...changes }) as Draft)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => void flush(), 800)
}
// ذخیره تغییرات در انتظار هنگام ترک صفحه — فقط برای مقاله ساخته‌شده
// (create در unmount ممنوع: با draft قدیمی، مقاله سرگردانی می‌سازد)
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
const changes = pendingRef.current
if (!changes || !createdRef.current) return
void adminApi.patch('articles', createdRef.current, changes).catch(() => {
/* صفحه در حال بستن است */
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const forceSave = () => void runSave(flush)
// محافظ مسیر — پس از همه هوک‌ها تا ترتیب فراخوانی هوک‌ها همیشه یکسان بماند
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
const remove = async () => {
if (!createdId) return
const result = await Swal.fire({
icon: 'warning',
title: 'حذف مقاله',
text: 'این مقاله برای همیشه حذف شود؟',
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
})
if (!result.isConfirmed) return
setDeleting(true)
await runSave(async () => {
await adminApi.remove('articles', createdId)
navigate('/blog')
})
setDeleting(false)
}
const textField = (name: keyof Draft) => ({
value: String(draft[name] ?? ''),
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
queueSave({ [name]: e.target.value }),
})
return (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
{/* نوار بالا */}
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
<Link
to="/blog"
data-admin-link
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
<ArrowRightIcon className="h-4 w-4" />
بازگشت به وبلاگ
</Link>
<div className="flex items-center gap-4">
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
{STATUS_TEXT[saveStatus]}
</span>
<button
type="button"
onClick={forceSave}
className="border border-copper/50 px-5 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
ذخیره
</button>
<span
className={`border px-3 py-2 text-xs font-light ${
draft.status === 'draft'
? 'border-amber-400/50 text-amber-300'
: 'border-emerald-400/50 text-emerald-300'
}`}
>
{draft.status === 'draft' ? 'پیش‌نویس' : 'منتشر شده'}
</span>
{createdId && (
<>
<button
type="button"
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
draft.status === 'draft'
? 'bg-copper text-char hover:scale-105'
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button>
<Link
to={`/blog/${draft.slug}`}
data-admin-link
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
>
<EyeIcon className="h-3.5 w-3.5" />
دیدن در سایت
</Link>
<button
type="button"
onClick={remove}
disabled={deleting}
aria-label="حذف مقاله"
className="flex h-8 w-8 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
<p className="text-xs font-light text-copper">
{!createdId
? 'نوشتن مقاله جدید — به‌صورت پیش‌نویس ذخیره می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود'
: draft.status === 'draft'
? 'پیش‌نویس — تا انتشار در سایت عمومی دیده نمی‌شود؛ تغییرها ذخیره می‌شوند'
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p>
{/* عنوان */}
<input
type="text"
{...textField('title')}
placeholder="عنوان مقاله…"
aria-label="عنوان مقاله"
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
/>
<div className="mt-4 h-px w-28 bg-copper" />
{/* تصویر شاخص */}
<div className="mt-8">
<ImageField
label="تصویر شاخص مقاله"
value={draft.image}
onSave={async (url) => {
queueSave({ image: url })
return true
}}
/>
</div>
{/* متن */}
<div className="mt-8">
<ArticleEditor
doc={draft.body as unknown as Record<string, unknown>}
onChange={(json) => queueSave({ body: json })}
/>
</div>
{/* چکیده و متادیتا */}
<SeoChecklist
input={{
title: draft.title,
slug: draft.slug,
excerpt: draft.excerpt,
body: draft.body,
keyphrase: draft.seo_keyphrase,
}}
/>
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
<h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2>
<div className="mt-5 grid gap-5">
<div>
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label>
<input
id="seo-keyphrase"
type="text"
value={draft.seo_keyphrase}
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
rows={2}
{...textField('excerpt')}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
<div>
<label className="text-xs font-light text-ivory/50">دسته</label>
<input
type="text"
{...textField('category')}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div>
<label className="text-xs font-light text-ivory/50">تاریخ</label>
<input
type="text"
{...textField('date')}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div>
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
<input
type="text"
{...textField('read')}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div>
<label className="text-xs font-light text-ivory/50">نشانی (slug)</label>
<input
type="text"
dir="ltr"
{...textField('slug')}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
</div>
</div>
</section>
</main>
</div>
)
}
+290
View File
@@ -0,0 +1,290 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { Link } from 'react-router-dom'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import type { SiteData } from '../api'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
import { ENTITIES } from './entities'
import { EntityDrawer } from './EntityDrawer'
type ItemWithId = Record<string, unknown> & { id: number }
function useEntityList(resource: string): ItemWithId[] {
const { data } = useSiteData()
const config = ENTITIES[resource]
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
}
function PillButton({
onClick,
label,
children,
danger = false,
}: {
onClick: () => void
label: string
children: ReactNode
danger?: boolean
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClick()
}}
className={`flex h-7 w-7 items-center justify-center border backdrop-blur-sm transition-colors duration-150 ${
danger
? 'border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20'
: 'border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char'
}`}
>
{children}
</button>
)
}
/**
* بسته‌بندی آیتم‌های مجموعه‌ها — فقط در حالت ویرایش ادمین، دکمه‌های شناور
* (ویرایش / بالا / پایین / حذف) روی آیتم نشان می‌دهد؛ در حالت عادی خروجی یکسان است.
*/
export function EditableItem({
resource,
item,
children,
className = '',
editHref,
}: {
resource: string
item: ItemWithId
children: ReactNode
className?: string
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */
editHref?: string
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const { reload } = useSiteData()
const config = ENTITIES[resource]
const list = useEntityList(resource)
const [drawerOpen, setDrawerOpen] = useState(false)
if (!config) return <>{children}</>
if (!isAuthenticated || !isEditMode) return <>{children}</>
const index = list.findIndex((x) => x.id === item.id)
const move = (delta: number) => {
const target = index + delta
if (target < 0 || target >= list.length) return
const ids = list.map((x) => x.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
void runSave(async () => {
await adminApi.reorder(config.resource, ids)
reload()
})
}
const remove = async () => {
const result = await Swal.fire({
icon: 'warning',
title: 'حذف رکورد',
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
})
if (!result.isConfirmed) return
void runSave(async () => {
await adminApi.remove(config.resource, item.id)
reload()
})
}
return (
<div className={`relative group/editable ${className}`}>
<div className="absolute left-2 top-2 z-30 flex flex-col gap-1.5 opacity-0 transition-opacity duration-150 group-hover/editable:opacity-100">
{editHref ? (
<Link
to={editHref}
data-admin-link
title="ویرایش"
aria-label="ویرایش"
className="flex h-7 w-7 items-center justify-center border border-copper/50 bg-ink/90 text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char"
>
<PencilIcon className="h-3.5 w-3.5" />
</Link>
) : (
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
)}
{index > 0 && (
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
<ArrowUpIcon className="h-3.5 w-3.5" />
</PillButton>
)}
{index < list.length - 1 && (
<PillButton onClick={() => move(1)} label="انتقال به بعد">
<ArrowDownIcon className="h-3.5 w-3.5" />
</PillButton>
)}
<PillButton onClick={remove} label="حذف" danger>
<Trash2Icon className="h-3.5 w-3.5" />
</PillButton>
</div>
{children}
{drawerOpen && (
<EntityDrawer config={config} item={item} deletable onClose={() => setDrawerOpen(false)} />
)}
</div>
)
}
/** دکمه افزودن آیتم جدید به یک مجموعه — فقط در حالت ویرایش */
export function AddItemButton({
resource,
label,
variant = 'cell',
}: {
resource: string
label?: string
variant?: 'cell' | 'button'
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const { updateSite } = useSiteData()
const config = ENTITIES[resource]
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null
const create = () => {
void runSave(async () => {
const created = await adminApi.create<ItemWithId>(config.resource, config.defaults)
// درج خوش‌بینانه — reload کل درخت را بازسازی می‌کند و کشوی تازه‌ساخته‌شده می‌میرد
updateSite((d) => {
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(created)
})
setNewItem(created)
})
}
const drawer = newItem ? (
<EntityDrawer config={config} item={newItem} deletable onClose={() => setNewItem(null)} />
) : null
if (variant === 'button') {
return (
<>
<button
type="button"
onClick={create}
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
>
<PlusIcon className="h-4 w-4" />
{label ?? `افزودن ${config.title}`}
</button>
{drawer}
</>
)
}
return (
<>
<button
type="button"
onClick={create}
className="flex min-h-40 w-full flex-col items-center justify-center gap-2 border border-dashed border-copper/40 bg-copper/5 text-copper transition-colors duration-200 hover:bg-copper/10"
>
<PlusIcon className="h-6 w-6" />
<span className="text-sm font-light">افزودن {config.title}</span>
</button>
{drawer}
</>
)
}
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را می‌سازد. */
export function SingletonPill({
resource,
label,
className = '',
select,
createDefaults,
}: {
resource: string
label: string
className?: string
/** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const config = ENTITIES[resource]
const { data, updateSite } = useSiteData()
const [drawerOpen, setDrawerOpen] = useState(false)
const [created, setCreated] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null
const item =
created ??
(select
? select(data!)
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined))
if (!item && !createDefaults) return null
const create = () => {
const defaults =
typeof createDefaults === 'function' ? createDefaults() : (createDefaults ?? {})
void runSave(async () => {
const rec = await adminApi.create<ItemWithId>(config.resource, defaults)
// درج خوش‌بینانه در درخت داده — دفعه بعد از داده انتخاب می‌شود
updateSite((d) => {
const list = d[config.dataKey as keyof NonNullable<typeof d>] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(rec)
})
setCreated(rec)
setDrawerOpen(true)
})
}
return (
<>
<span className={className}>
{item ? (
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
) : (
<PillButton onClick={create} label={`ساخت — ${label}`}>
<PlusIcon className="h-3.5 w-3.5" />
</PillButton>
)}
</span>
{drawerOpen && item && (
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
)}
</>
)
}
+339
View File
@@ -0,0 +1,339 @@
import { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient'
import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField'
import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = {
config: EntityConfig
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
item: Record<string, unknown> & { id: number }
onClose: () => void
/** فقط برای مجموعه‌ها: حذف آیتم */
deletable?: boolean
}
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
const save = useFieldSaver()
const { runSave } = useAdmin()
const { reload } = useSiteData()
const [deleting, setDeleting] = useState(false)
const flushRef = useRef<() => void>(() => {})
const closeWithFlush = () => {
flushRef.current()
onClose()
}
const saveField = (name: string, value: unknown) =>
save(config.resource, item.id, { [name]: value }, (d) => {
const target = d[config.dataKey as keyof typeof d] as unknown
const node =
config.kind === 'singleton'
? (target as Record<string, unknown>)
: (target as { id: number }[]).find((x) => x.id === item.id)
if (node) (node as Record<string, unknown>)[name] = value
})
const remove = async () => {
const result = await Swal.fire({
icon: 'warning',
title: 'حذف رکورد',
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
})
if (!result.isConfirmed) return
setDeleting(true)
await runSave(async () => {
await adminApi.remove(config.resource, item.id)
reload()
onClose()
})
setDeleting(false)
}
return (
<AnimatePresence>
<motion.div
key="backdrop"
className="fixed inset-0 z-[95] bg-char/70 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={closeWithFlush}
aria-hidden="true"
/>
<motion.aside
key="drawer"
dir="rtl"
role="dialog"
aria-label={`ویرایش ${config.title}`}
className={`fixed inset-y-0 left-0 z-[96] flex w-full flex-col border-e border-ivory/10 bg-ink shadow-2xl ${
config.resource === 'articles' ? 'sm:max-w-3xl' : 'sm:max-w-md'
}`}
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
>
<header className="flex items-center justify-between border-b border-ivory/10 px-6 py-4">
<div>
<p className="text-xs font-light text-copper">
{config.kind === 'singleton' ? 'تنظیمات بخش' : 'ویرایش رکورد'}
</p>
<h2 className="mt-1 text-lg font-bold text-ivory">{config.title}</h2>
</div>
<button
type="button"
onClick={closeWithFlush}
aria-label="بستن"
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6">
{config.fields.map((field) => (
<FieldControl
key={field.name}
field={field}
value={item[field.name]}
onSave={(value) => saveField(field.name, value)}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
flushRef.current = fn
}
: undefined
}
/>
))}
</div>
</div>
{deletable && (
<footer className="border-t border-ivory/10 px-6 py-4">
<button
type="button"
onClick={remove}
disabled={deleting}
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2Icon className="h-3.5 w-3.5" />
حذف این رکورد
</button>
</footer>
)}
</motion.aside>
</AnimatePresence>
)
}
function FieldControl({
field,
value,
onSave,
registerFlush,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [text, setText] = useState<string>(String(value ?? ''))
const commitText = async () => {
const next = field.type === 'number' ? Number(text) || 0 : text
if (String(value ?? '') === String(next)) return
await onSave(next)
}
if (field.type === 'image') {
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
}
if (field.type === 'wysiwyg') {
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
}
if (field.type === 'list') {
return <ListField field={field} value={value} onSave={onSave} />
}
if (field.type === 'textarea') {
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
<textarea
dir="auto"
rows={4}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</div>
)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
<input
dir={field.type === 'number' ? 'ltr' : 'auto'}
type={field.type === 'number' ? 'number' : 'text'}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
)
}
/** ویرایش آرایه خطی (پاراگراف‌ها، گالری، نکات) — هر تغییر ذخیره می‌شود */
function ListField({
field,
value,
onSave,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
}) {
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
const update = (next: string[]) => {
setLines(next)
void onSave(next)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
<div className="mt-2 grid gap-2">
{lines.map((line, i) => (
<div key={i} className="flex items-start gap-2">
<textarea
dir="auto"
rows={line.length > 60 ? 3 : 1}
value={line}
onChange={(e) => {
const next = [...lines]
next[i] = e.target.value
setLines(next)
}}
onBlur={() => {
if (line !== (value as string[])[i]) update(lines)
}}
className="flex-1 resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
<button
type="button"
aria-label="حذف خط"
onClick={() => update(lines.filter((_, j) => j !== i))}
className="mt-1 text-ivory/40 transition-colors hover:text-red-300"
>
<XIcon className="h-4 w-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => update([...lines, ''])}
className="flex w-fit items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1.5 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
>
<PlusIcon className="h-3 w-3" />
افزودن خط
</button>
</div>
</div>
)
}
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
function WysiwygField({
field,
value,
onSave,
registerFlush,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: { type: 'doc', content: [{ type: 'paragraph' }] },
)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null)
const flush = () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current) {
const next = pending.current
pending.current = null
void onSave(next)
}
}
// ثبت آخرین flush در کشو + ذخیره تغییرات در انتظار هنگام unmount
useEffect(() => {
registerFlush?.(() => flush())
return () => {
if (timer.current) clearTimeout(timer.current)
if (pending.current) {
const next = pending.current
pending.current = null
void onSave(next)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
})
const handleChange = (json: Record<string, unknown>) => {
setDoc(json)
pending.current = json
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(flush, 800)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
<p className="mt-1 text-[10px] font-light text-ivory/40">
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
</p>
<div className="mt-2">
<ArticleEditor doc={doc} onChange={handleChange} />
</div>
</div>
)
}
+97
View File
@@ -0,0 +1,97 @@
// لایه تماس ادمین — همه درخواست‌ها با توکن Bearer و مدیریت خطای یکسان
import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token'
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string | null): void => {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
}
}
async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const isFormData = options.body instanceof FormData
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: {
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.body && !isFormData ? { 'Content-Type': 'application/json' } : {}),
},
})
if (res.status === 401) {
setToken(null)
throw new HttpError(401, 'نشست شما منقضی شده — دوباره وارد شوید')
}
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
const detail =
typeof data?.detail === 'string'
? data.detail
: res.status === 409
? 'رکوردی با این مقدار تکراری وجود دارد'
: 'خطا در ذخیره‌سازی — دوباره تلاش کنید'
throw new HttpError(res.status, detail)
}
return res.status === 204 ? (null as T) : ((await res.json()) as T)
}
export const adminApi = {
login: async (username: string, password: string): Promise<string> => {
const res = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
throw new HttpError(
res.status,
typeof data?.detail === 'string' ? data.detail : 'ورود ناموفق بود',
)
}
const data = (await res.json()) as { access_token: string }
setToken(data.access_token)
return data.access_token
},
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
list: <T = unknown>(resource: string) => authFetch<T>(`/api/admin/${resource}`),
get: <T = unknown>(resource: string, id: number) =>
authFetch<T>(`/api/admin/${resource}/${id}`),
create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
remove: (resource: string, id: number) =>
authFetch<null>(`/api/admin/${resource}/${id}`, { method: 'DELETE' }),
reorder: <T = unknown>(resource: string, ids: number[]) =>
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
upload: async (file: File): Promise<{ url: string; key: string }> => {
const fd = new FormData()
fd.append('file', file)
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
},
changePassword: (currentPassword: string, newPassword: string) =>
authFetch<{ ok: boolean }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
}
+203
View File
@@ -0,0 +1,203 @@
import type { SiteData } from '../api'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg'
export type FieldConfig = {
name: string
label: string
type: FieldType
hint?: string
}
export type EntityConfig = {
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
resource: string
/** کلید همان منبع در پاسخ GET /api/site */
dataKey: string
title: string
/** مجموعه یا رکورد یگانه */
kind: 'collection' | 'singleton'
/** فیلدی که عنوان آیتم را نشان می‌دهد */
titleKey: string
fields: FieldConfig[]
/** مقادیر پیش‌فرض برای «افزودن» */
defaults: Record<string, unknown>
}
const F = (name: string, label: string, type: FieldType = 'text', hint?: string): FieldConfig => ({
name,
label,
type,
hint,
})
/** پیکربندی همه منابع قابل ویرایش — هم مجموعه‌ها و هم رکوردهای یگانه */
export const ENTITIES: Record<string, EntityConfig> = {
services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
},
projects: {
resource: 'projects', dataKey: 'projects', title: 'پروژه‌ها', kind: 'collection',
titleKey: 'title',
fields: [
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
],
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
},
articles: {
resource: 'articles', dataKey: 'articles', title: 'مقالات', kind: 'collection',
titleKey: 'title',
fields: [
F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده', 'textarea'),
F('image', 'تصویر شاخص', 'image'),
F('category', 'دسته'),
F('date', 'تاریخ'),
F('read', 'زمان مطالعه'),
F('slug', 'نشانی (slug)'),
],
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
},
testimonials: {
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
titleKey: 'name',
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
defaults: { name: 'نظر جدید', role: '', quote: '' },
},
faqs: {
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
titleKey: 'question',
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
defaults: { question: 'سوال جدید', answer: '' },
},
team: {
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
titleKey: 'name',
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
defaults: { name: 'عضو جدید', role: '', bio: '' },
},
timeline: {
resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection',
titleKey: 'title',
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
},
stats: {
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
titleKey: 'label',
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
},
why_us: {
resource: 'why-us', dataKey: 'why_us', title: 'چرا آرامش', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
defaults: { title: 'مورد جدید', desc: '' },
},
investment_models: {
resource: 'investment-models', dataKey: 'investment_models', title: 'مدل‌های سرمایه‌گذاری', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('points', 'نکات (هر خط یک مورد)', 'list')],
defaults: { title: 'مدل جدید', desc: '', points: [] },
},
process_steps: {
resource: 'process-steps', dataKey: 'process_steps', title: 'مراحل فرآیند کار', kind: 'collection',
titleKey: 'title',
fields: [F('step', 'شماره مرحله'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
defaults: { step: '۰۱', title: 'مرحله جدید', desc: '' },
},
offices: {
resource: 'offices', dataKey: 'offices', title: 'دفاتر', kind: 'collection',
titleKey: 'city',
fields: [F('city', 'شهر'), F('address', 'نشانی', 'textarea'), F('hours', 'ساعات کاری')],
defaults: { city: 'شهر جدید', address: '', hours: '' },
},
gallery: {
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
titleKey: 'alt',
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
defaults: { src: '', alt: 'تصویر گالری' },
},
nav: {
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
titleKey: 'label',
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
defaults: { label: 'آیتم جدید', href: '/' },
},
seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key',
fields: [
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
],
defaults: { page_key: '', title: '', description: '' },
},
// ---------- رکوردهای یگانه ----------
brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name',
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پس‌زمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
defaults: {},
},
hero: {
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
titleKey: 'title',
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
defaults: {},
},
story: {
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
titleKey: 'title',
fields: [F('quote', 'نقل‌قول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقل‌قول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
defaults: {},
},
before_after: {
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
defaults: {},
},
contact_section: {
resource: 'contact-section', dataKey: 'contact_section', title: 'فرم تماس', kind: 'singleton',
titleKey: 'title',
fields: [
F('label', 'برچسب بالا'), F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'),
F('address_label', 'برچسب نشانی'), F('phone_label', 'برچسب تلفن'), F('email_label', 'برچسب ایمیل'),
F('name_label', 'برچسب نام'), F('name_placeholder', 'جای‌نگهدار نام'),
F('phone_field_label', 'برچسب تلفن (فرم)'), F('phone_placeholder', 'جای‌نگهدار تلفن'),
F('note_label', 'برچسب توضیحات'), F('note_optional_label', 'برچسب اختیاری'), F('note_placeholder', 'جای‌نگهدار توضیحات'),
F('submit_label', 'متن دکمه ارسال'), F('sending_label', 'متن در حال ارسال'),
F('success_title', 'عنوان پیام موفقیت'), F('success_text', 'متن پیام موفقیت', 'textarea'), F('success_button_label', 'دکمه پیام موفقیت'),
],
defaults: {},
},
newsletter: {
resource: 'newsletter', dataKey: 'newsletter', title: 'خبرنامه', kind: 'singleton',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('placeholder', 'جای‌نگهدار ایمیل'), F('button_label', 'متن دکمه'), F('sending_label', 'متن در حال ارسال'), F('success_text', 'پیام موفقیت')],
defaults: {},
},
footer: {
resource: 'footer', dataKey: 'footer', title: 'فوتر', kind: 'singleton',
titleKey: 'pages_heading',
fields: [F('about_text', 'متن معرفی', 'textarea'), F('pages_heading', 'تیتر ستون صفحات'), F('services_heading', 'تیتر ستون خدمات'), F('contact_heading', 'تیتر ستون تماس'), F('copyright', 'متن کپی‌رایت'), F('tagline', 'شعار پایین')],
defaults: {},
},
}
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
return data[config.dataKey as keyof SiteData] as never
}
+285
View File
@@ -0,0 +1,285 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site'
import { API_URL } from './config'
import { getToken } from './admin/adminClient'
// انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند
export type BrandInfo = {
id: number
name: string
logo: string
hero: string
contact_bg: string
phone: string
phone_href: string
email: string
address: string
}
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
export type FaqItem = { id: number; question: string; answer: string }
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
export type StatItem = { id: number; value: number; suffix: string; label: string }
export type WhyUsItem = { id: number; title: string; desc: string }
export type BeforeAfterInfo = {
id: number
title: string
lead: string
before: string
after: string
before_label: string
after_label: string
}
export type NavItem = { id: number; label: string; href: string }
export type HeroContent = {
id: number
kicker: string
title: string
title_highlight: string
subtitle: string
badge_value: string
badge_label: string
cta_primary_label: string
cta_primary_href: string
cta_secondary_label: string
cta_secondary_href: string
scroll_label: string
}
export type StoryContent = {
id: number
title: string
paragraphs: string[]
quote: string
quote_author: string
stat1_value: string
stat1_label: string
stat2_value: string
stat2_label: string
link_label: string
link_href: string
bento: { src: string; alt: string }[]
}
export type SectionHeadingContent = {
id: number
key: string
label: string
title: string
lead: string
image: string
cta_label: string
cta_href: string
}
export type PageHeroContent = { id: number; key: string; title: string; lead: string; image: string }
export type InvestmentModelItem = { id: number; title: string; desc: string; points: string[] }
export type ProcessStepItem = { id: number; step: string; title: string; desc: string }
export type OfficeItem = { id: number; city: string; address: string; hours: string }
export type GalleryImageItem = { id: number; src: string; alt: string }
export type StatusLabelItem = { id: number; key: string; label: string }
export type ContactSectionContent = {
id: number
label: string
title: string
lead: string
address_label: string
phone_label: string
email_label: string
name_label: string
name_placeholder: string
phone_field_label: string
phone_placeholder: string
note_label: string
note_optional_label: string
note_placeholder: string
submit_label: string
sending_label: string
success_title: string
success_text: string
success_button_label: string
}
export type NewsletterContent = {
id: number
title: string
lead: string
placeholder: string
button_label: string
sending_label: string
success_text: string
}
export type SocialItem = { label: string; href: string; icon: string }
export type FooterContent = {
id: number
about_text: string
pages_heading: string
services_heading: string
contact_heading: string
copyright: string
tagline: string
socials: SocialItem[]
}
export interface SiteData {
brand: BrandInfo
nav: NavItem[]
hero: HeroContent
story: StoryContent
section_headings: SectionHeadingContent[]
page_heroes: PageHeroContent[]
services: Service[]
projects: Project[]
articles: Article[]
testimonials: TestimonialItem[]
faqs: FaqItem[]
team: TeamMemberItem[]
timeline: TimelineItem[]
stats: StatItem[]
why_us: WhyUsItem[]
before_after: BeforeAfterInfo
investment_models: InvestmentModelItem[]
process_steps: ProcessStepItem[]
offices: OfficeItem[]
gallery: GalleryImageItem[]
status_labels: StatusLabelItem[]
contact_section: ContactSectionContent
newsletter: NewsletterContent
footer: FooterContent
seo_meta: { id: number; page_key: string; title: string; description: string }[]
}
interface SiteContextValue {
data: SiteData | null
error: boolean
reload: () => void
/** به‌روزرسانی خوش‌بینانه درخت داده — پس از موفقیت PATCH استفاده می‌شود */
updateSite: (mutate: (data: SiteData) => void) => void
}
const SiteContext = createContext<SiteContextValue>({
data: null,
error: false,
reload: () => {},
updateSite: () => {},
})
export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null)
const [error, setError] = useState(false)
const load = useCallback(() => {
let cancelled = false
setError(false)
fetch(`${API_URL}/api/site`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<SiteData>
})
.then(async (json) => {
if (cancelled) return
// برای ادمین واردشده، پیش‌نویس مقاله‌ها از انتهای مدیریتی خوانده و ادغام می‌شوند —
// بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند
if (getToken()) {
try {
const res = await fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (res.ok) {
const all = (await res.json()) as Article[]
const published = new Set(json.articles.map((a) => a.id))
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
json.articles = [...json.articles, ...drafts]
}
} catch {
/* بدون پیش‌نویس‌ها ادامه بده */
}
}
if (!cancelled) {
if (json.brand && json.hero && json.footer && json.nav) setData(json)
else setError(true)
}
})
.catch(() => {
if (!cancelled) setError(true)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => load(), [load])
const reload = useCallback(() => {
setData(null)
setError(false)
load()
}, [load])
const updateSite = useCallback((mutate: (data: SiteData) => void) => {
setData((prev) => {
if (!prev) return prev
const next = structuredClone(prev)
mutate(next)
return next
})
}, [])
return (
<SiteContext.Provider value={{ data, error, reload, updateSite }}>
{children}
</SiteContext.Provider>
)
}
export function useSiteData(): SiteContextValue {
return useContext(SiteContext)
}
// فقط در کامپوننت‌هایی استفاده شود که پس از بارگذاری کامل داده‌ها رندر می‌شوند
export function useSite(): SiteData {
const { data } = useContext(SiteContext)
if (!data) {
throw new Error('useSite باید پس از بارگذاری داده‌های سایت استفاده شود')
}
return data
}
const EMPTY_HEADING: SectionHeadingContent = {
id: 0,
key: '',
label: '',
title: '',
lead: '',
image: '',
cta_label: '',
cta_href: '',
}
/** تیتر و توضیح یک بخش بر اساس key تعریف‌شده در پنل مدیریت */
export function useSectionHeading(key: string): SectionHeadingContent {
const { data } = useContext(SiteContext)
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
}
/** هیرو بالای صفحه بر اساس key تعریف‌شده در پنل مدیریت */
export function usePageHero(key: string): PageHeroContent {
const { data } = useContext(SiteContext)
return (
data?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
)
}
/** برچسب فارسی وضعیت پروژه (done | progress) از پنل مدیریت */
export function useStatusLabel(key: string): string {
const { data } = useContext(SiteContext)
return data?.status_labels.find((s) => s.key === key)?.label ?? key
}
+52
View File
@@ -0,0 +1,52 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
export function ArticleCard({ article }: { article: Article }) {
return (
<Link
to={`/blog/${article.slug}`}
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`خواندن مقاله ${article.title}`}
>
<div className="relative overflow-hidden">
<img
src={article.image}
alt={`تصویر مقاله ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/>
{/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */}
<div className="absolute right-4 top-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
{article.category}
</div>
</div>
<div className="flex flex-1 flex-col p-7">
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
{article.title}
</h3>
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
{article.excerpt}
</p>
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
<span className="flex items-center gap-3">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{article.read}
</span>
</span>
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
</span>
</div>
</div>
</Link>
)
}
+110
View File
@@ -0,0 +1,110 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
// استایل‌های فوق‌لوکس شیشه‌ای (Glassmorphic) با رنگ‌های اختصاصی پروژه
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
copper: {
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
text: 'group-hover:text-char font-semibold',
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
},
outline: {
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبه‌ها
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
fill: 'bg-white/10',
text: 'group-hover:text-copper',
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
},
dark: {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-copper',
text: 'group-hover:text-char',
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
},
'dark-ivory': {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-ivory',
text: 'group-hover:text-char',
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
{/* خط انعکاس نوری شیشه در بالای دکمه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
{/* انیمیشن پر شدن موجی و نرم */}
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
/>
{/* متن و آیکون دکمه */}
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
>
{children}
</span>
</>
)
}
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشه‌ای
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
const style = STYLES[variant]
return (
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const style = STYLES[variant]
return (
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const style = STYLES[variant]
return (
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+277
View File
@@ -0,0 +1,277 @@
import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from './Reveal'
import { Button } from './Button'
import { useSite } from '../api'
import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit'
import { SingletonPill } from '../admin/Editable'
import { API_URL } from '../config'
export function ContactSection() {
const { brand, contact_section: content } = useSite()
const save = useFieldSaver()
const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
const saveContent = (field: string, value: string) =>
save('contact-section', content.id, { [field]: value }, (d) => {
;(d.contact_section as unknown as Record<string, unknown>)[field] = value
})
const saveBrand = (field: string, value: string) =>
save('brand', brand.id, { [field]: value }, (d) => {
;(d.brand as unknown as Record<string, unknown>)[field] = value
})
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
title: title,
text: text,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-md bg-ink/70',
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
},
showClass: {
popup: 'swal2-show',
backdrop: 'swal2-backdrop-show',
},
hideClass: {
popup: 'swal2-hide',
backdrop: 'swal2-backdrop-hide',
},
})
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (sending) return
const nameValue = form.name.trim()
const phoneValue = form.phone.trim()
if (nameValue.length < 3) {
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
return
}
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
if (!phoneValue || !phoneRegex.test(phoneValue)) {
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
return
}
setSending(true)
try {
const res = await fetch(`${API_URL}/api/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
})
if (!res.ok) {
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در ثبت درخواست', detail)
return
}
setSent(true)
} catch {
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally {
setSending(false)
}
}
return (
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
<img
src={brand.contact_bg}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
<div className="relative mx-auto max-w-site px-6 lg:px-10">
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
{/* باکس اطلاعات تماس */}
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
<Reveal>
<p className="text-sm font-light tracking-wide text-copper">
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
</p>
<h2 id="contact-title" className="mt-4 text-section font-bold text-ivory">
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/75">
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
</p>
</Reveal>
<Reveal delay={0.1}>
<ul className="mt-10 space-y-6">
<li className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<MapPinIcon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} />
</p>
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">
<InlineEdit value={brand.address} onSave={(v) => saveBrand('address', v)} />
</p>
</div>
</li>
<li className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<PhoneIcon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
</p>
<a
href={brand.phone_href}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
</a>
</div>
</li>
<li className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<MailIcon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
</p>
<a
href={`mailto:${brand.email}`}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
</a>
</div>
</li>
</ul>
</Reveal>
</div>
{/* باکس فرم ارسال درخواست */}
<div className="relative rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-7">
<SingletonPill resource="contact_section" label="ویرایش برچسب‌ها و متن‌های فرم" className="absolute left-4 top-4" />
{sent ? (
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper shadow-[0_0_30px_rgba(197,143,92,0.4)]">
<CheckCircle2Icon className="h-8 w-8" />
</div>
<h3 className="mt-6 text-xl font-bold text-ivory">
<InlineEdit value={content.success_title} onSave={(v) => saveContent('success_title', v)} />
</h3>
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
</p>
<Button
type="button"
variant="outline"
className="mt-8 font-light"
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '' })
}}
>
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
</Button>
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
</label>
<input
id="contact-name"
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={content.name_placeholder}
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
/>
</div>
<div>
<label htmlFor="contact-phone" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
</label>
<input
id="contact-phone"
type="tel"
dir="ltr"
required
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder={content.phone_placeholder}
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
/>
</div>
<div className="md:col-span-2">
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
</label>
<textarea
id="contact-note"
rows={5}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
placeholder={content.note_placeholder}
className="mt-3 w-full resize-none rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light leading-8 text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
/>
</div>
<div className="md:col-span-2 flex justify-start">
<Button
type="submit"
variant="copper"
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
disabled={sending}
>
{sending ? (
content.sending_label
) : (
<>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
</>
)}
</Button>
</div>
</form>
)}
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,30 @@
import React from 'react';
import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api';
export function FloatingContact() {
const { brand } = useSite();
return (
<a
href={brand.phone_href}
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.3),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
>
{/* پالس هاله نوری در پس‌زمینه مانند رادار */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
/>
{/* خط انعکاس نوری کریستال در بالای گوی */}
<span
aria-hidden="true"
className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/25 to-transparent"
/>
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
</a>
);
}
+152
View File
@@ -0,0 +1,152 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { useSite } from '../api';
import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from './admin/InlineEdit';
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
instagram: InstagramIcon,
linkedin: LinkedinIcon,
telegram: SendIcon,
};
export function Footer() {
const { brand, services, nav, footer } = useSite();
const save = useFieldSaver();
const saveFooter = (field: string, value: string) =>
save('footer', footer.id, { [field]: value }, (d) => {
;(d.footer as unknown as Record<string, unknown>)[field] = value;
});
const saveNav = (id: number, changes: Record<string, unknown>) =>
save('nav', id, changes, (d) => {
const item = d.nav.find((n) => n.id === id);
if (item) Object.assign(item, changes);
});
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
<div className="grid gap-12 lg:grid-cols-12">
<div className="lg:col-span-4">
<div className="relative w-fit">
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
</div>
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
</p>
{/* آیکون‌های شبکه‌های اجتماعی: گوی‌های شیشه‌ای شناور */}
<div className="mt-8 flex items-center gap-3">
{footer.socials.map(({ label, href, icon }) => {
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
return (
<a
key={label}
href={href}
aria-label={label}
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
>
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
</a>
);
})}
</div>
</div>
<div className="lg:col-span-2">
<h3 className="text-sm font-bold text-copper">
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
</h3>
<ul className="mt-5 space-y-3">
{nav.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<Link
to={item.href}
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
>
<InlineEdit
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
</Link>
</li>
</EditableItem>
))}
</ul>
<div className="mt-4">
<AddItemButton resource="nav" />
</div>
</div>
<div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper">
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
</h3>
<ul className="mt-5 space-y-3">
{services.map((service) => (
<li key={service.id} className="text-sm font-light text-ivory/60">
{service.title}
</li>
))}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper">
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
</h3>
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
<li className="flex items-start gap-3">
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
<span className="leading-7">{brand.address}</span>
</li>
<li className="flex items-center gap-3">
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
{brand.phone}
</a>
</li>
<li className="flex items-center gap-3">
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
{brand.email}
</a>
</li>
</ul>
</div>
</div>
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
<p>
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
</p>
<button
type="button"
onClick={scrollToTop}
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
>
<span>بازگشت به بالا</span>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
<ArrowUpIcon className="h-3 w-3" />
</span>
</button>
<p>
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
</p>
</div>
</div>
</footer>
);
}
+152
View File
@@ -0,0 +1,152 @@
import React, { useEffect, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
import { useSite } from "../api";
import { useFieldSaver } from "../admin/AdminContext";
import { InlineEdit } from "./admin/InlineEdit";
import { EditableItem, SingletonPill } from "../admin/Editable";
export function Header() {
const { brand, nav } = useSite();
const save = useFieldSaver();
const saveNav = (id: number, changes: Record<string, unknown>) =>
save("nav", id, changes, (d) => {
const item = d.nav.find((n) => n.id === id);
if (item) Object.assign(item, changes);
});
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const { pathname } = useLocation();
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
});
useEffect(() => {
setOpen(false);
}, [pathname]);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-all duration-300 ease-lux ${
scrolled
? "border-b border-white/10 bg-ink/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)]"
: "border-b border-transparent bg-transparent"
}`}
>
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
<div className="relative flex items-center">
<Link
to="/"
className="flex items-center gap-3"
aria-label={brand.name}
>
<img
src={brand.logo}
alt={brand.name}
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
scrolled ? "h-14" : "h-20"
}`}
/>
</Link>
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
</div>
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
<ul className="flex items-center gap-7">
{nav.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<NavLink
to={item.href}
className={({ isActive }) =>
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
isActive ? "text-copper font-normal" : "text-ivory/80"
}`
}
>
{({ isActive }) => (
<>
<InlineEdit
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
<span
className={`absolute -bottom-0.5 left-0 right-0 h-0.5 w-full origin-center rounded-full bg-copper transition-transform duration-300 ease-lux ${
isActive
? "scale-x-100 shadow-[0_0_8px_rgba(197,143,92,0.8)]"
: "scale-x-0 group-hover:scale-x-100"
}`}
/>
</>
)}
</NavLink>
</li>
</EditableItem>
))}
</ul>
</nav>
<div className="flex items-center gap-3">
{/* دکمه تماس کپسولی شیشه‌ای لوکس */}
<a
href={brand.phone_href}
className="group relative hidden items-center gap-3 rounded-full border border-copper/40 bg-white/[0.04] px-5 py-2 text-sm font-medium text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_20px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_25px_rgba(197,143,92,0.35)] active:scale-95 lg:flex"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-copper/20 text-copper transition-all duration-300 group-hover:bg-copper group-hover:text-char">
<PhoneIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
</span>
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
</a>
{/* دکمه منوی موبایل دایره‌ای شیشه‌ای */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
aria-label={open ? "بستن منو" : "باز کردن منو"}
aria-expanded={open}
>
{open ? (
<XIcon className="h-5 w-5" />
) : (
<MenuIcon className="h-5 w-5" />
)}
</button>
</div>
</div>
{open && (
<nav
aria-label="ناوبری موبایل"
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
>
<ul className="grid gap-1">
{nav.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<NavLink
to={item.href}
className={({ isActive }) =>
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
isActive ? "text-copper" : "text-ivory/80"
}`
}
>
<InlineEdit
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
</NavLink>
</li>
</EditableItem>
))}
</ul>
</nav>
)}
</header>
);
}
+100
View File
@@ -0,0 +1,100 @@
import React, { useCallback, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
type Props = {
images: string[]
index: number | null
onClose: () => void
onChange: (index: number) => void
}
export function Lightbox({ images, index, onClose, onChange }: Props) {
const isOpen = index !== null
const next = useCallback(() => {
if (index === null) return
onChange((index + 1) % images.length)
}, [index, images.length, onChange])
const prev = useCallback(() => {
if (index === null) return
onChange((index - 1 + images.length) % images.length)
}, [index, images.length, onChange])
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') prev()
if (e.key === 'ArrowLeft') next()
}
window.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
}, [isOpen, onClose, next, prev])
return (
<AnimatePresence>
{isOpen && (
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
>
<div
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
onClick={onClose}
aria-hidden="true"
/>
<button
type="button"
onClick={onClose}
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
>
<XIcon className="h-5 w-5" />
</button>
<motion.img
key={index}
src={images[index as number]}
alt=""
className="relative max-h-[80vh] w-auto max-w-full object-contain"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
<button
type="button"
onClick={prev}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
<span className="text-sm font-light text-ivory/70" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
<button
type="button"
onClick={next}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
View File
+89
View File
@@ -0,0 +1,89 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeftIcon } from 'lucide-react'
import { usePageHero } from '../api'
import { useFieldSaver } from '../admin/AdminContext'
import { SingletonPill } from '../admin/Editable'
import { InlineEdit } from './admin/InlineEdit'
import { ImageField } from './admin/ImageField'
type PageHeroProps = {
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایش‌پذیر می‌شوند */
pageKey: string
}
export function PageHero({ pageKey }: PageHeroProps) {
const hero = usePageHero(pageKey)
const save = useFieldSaver()
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
save('page-heroes', hero.id, { [field]: value }, (d) => {
const h = d.page_heroes.find((p) => p.id === hero.id)
if (h) (h as Record<string, unknown>)[field] = value
})
return (
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
صفحه اصلی
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{hero.title}</span>
</nav>
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-[700px]"
>
<InlineEdit
as="span"
value={hero.title}
onSave={(v) => saveField('title', v)}
className="block text-hero font-bold text-ivory"
/>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-2xl"
>
<InlineEdit
as="span"
multiline
value={hero.lead}
onSave={(v) => saveField('lead', v)}
className="block text-base font-light leading-9 text-ivory/65"
/>
</motion.p>
<div className="mt-8 h-px w-28 bg-copper" />
{/* ویرایش تصویر پس‌زمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
<SingletonPill
resource="seo-meta"
label="عنوان و توضیح سئوی این صفحه"
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
// اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همین‌جا ساخته می‌شود
createDefaults={() => ({ page_key: pageKey, title: '', description: '' })}
/>
<ImageField label="تصویر پس‌زمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
</div>
</div>
</section>
)
}
+81
View File
@@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useSiteData } from '../api'
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری داده‌ها از دیتابیس استفاده می‌شود
const FALLBACK_LOGO = '/images/logo.png'
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
export function Preloader() {
const { data } = useSiteData()
const [done, setDone] = useState(false)
const [progress, setProgress] = useState(0)
const logo = data?.brand.logo || FALLBACK_LOGO
const name = data?.brand.name || FALLBACK_NAME
useEffect(() => {
const start = Date.now()
const total = 1800
const id = window.setInterval(() => {
const p = Math.min(100, ((Date.now() - start) / total) * 100)
setProgress(p)
if (p >= 100) {
window.clearInterval(id)
window.setTimeout(() => setDone(true), 180)
}
}, 25)
return () => window.clearInterval(id)
}, [])
useEffect(() => {
document.body.style.overflow = done ? '' : 'hidden'
return () => {
document.body.style.overflow = ''
}
}, [done])
return (
<AnimatePresence>
{!done && (
<motion.div
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-ink"
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
aria-live="polite"
aria-label="در حال بارگذاری"
>
<div className="relative h-36 w-36">
<img
src={logo}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]"
/>
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(${100 - progress}% 0 0 0)` }}>
<img src={logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
</div>
<div
className="absolute inset-x-0 h-px bg-copper"
style={{ bottom: `${progress}%` }}
aria-hidden="true"
/>
</div>
<div className="mt-10 h-[2px] w-48 overflow-hidden bg-ivory/10">
<div className="h-full bg-copper" style={{ width: `${progress}%`, transition: 'width 120ms linear' }} />
</div>
<motion.p
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
>
{name}
</motion.p>
</motion.div>
)}
</AnimatePresence>
)
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon } from 'lucide-react'
import { useStatusLabel } from '../api'
export function ProjectCard({ project }: any) {
const isOngoing = project.status === 'progress'
const statusText = useStatusLabel(project.status)
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
return (
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */}
<div className="relative aspect-[4/3] w-full overflow-hidden">
<img
src={project.image}
alt={project.title}
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/>
{/* نشانگر وضعیت کپسولی شیشه‌ای */}
<div className={`absolute right-4 top-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
{statusText}
</div>
{/* گوی دکمه شیشه‌ای شناور برای ورود به پروژه */}
<div className="absolute bottom-4 left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
<ArrowLeftIcon className="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-0.5" />
</div>
</div>
<div className="flex flex-1 flex-col p-7">
<div className="text-center">
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
{project.title}
</h3>
<p className="mt-4 text-sm font-light text-ivory/60">
{project.location}
</p>
</div>
<div className="my-6 h-px w-full bg-white/10" />
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
<span>سال: {project.year}</span>
<span>{project.area} متر مربع</span>
</div>
</div>
</Link>
</article>
)
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { motion } from 'framer-motion';
type RevealProps = {
children: React.ReactNode;
delay?: number;
y?: number;
className?: string;
};
export function Reveal({ children, delay = 0, y = 28, className }: RevealProps) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.7, delay, ease: [0.23, 1, 0.32, 1] }}>
{children}
</motion.div>);
}
@@ -0,0 +1,58 @@
import React from 'react'
import { Reveal } from './Reveal'
import { useSectionHeading } from '../api'
import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit'
type SectionHeadingProps = {
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و به‌صورت درجا ویرایش‌پذیر می‌شوند */
headingKey?: string
title?: string
lead?: string
align?: 'right' | 'center'
tone?: 'copper' | 'ivory'
}
export function SectionHeading({
headingKey,
title,
lead,
align = 'right',
tone = 'ivory',
}: SectionHeadingProps) {
const heading = useSectionHeading(headingKey ?? '')
const save = useFieldSaver()
const isDb = !!headingKey
const currentTitle = isDb ? heading.title : (title ?? '')
const currentLead = isDb ? heading.lead : (lead ?? '')
const isCenter = align === 'center'
const saveField = (field: 'title' | 'lead', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id)
if (h) (h as Record<string, unknown>)[field] = value
})
return (
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
<InlineEdit
as="h2"
value={currentTitle}
onSave={(v) => saveField('title', v)}
className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}
/>
{currentLead && (
<InlineEdit
as="p"
multiline
value={currentLead}
onSave={(v) => saveField('lead', v)}
className="mt-5 text-base font-light leading-9 text-ivory/60"
/>
)}
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
</Reveal>
)
}
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import type { Service } from '../data/site';
type ServiceIconProps = {
name: Service['icon'];
className?: string;
};
const stroke = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const
};
export function ServiceIcon({ name, className = 'h-12 w-12' }: ServiceIconProps) {
return (
<svg viewBox="0 0 48 48" className={className} aria-hidden="true" role="presentation">
{name === 'draft' &&
<g {...stroke}>
<path d="M6 40h36" />
<path d="M10 40V14l14-8 14 8v26" />
<path d="M10 22h28M24 6v34" />
<circle cx="24" cy="22" r="3" />
</g>
}
{name === 'tower' &&
<g {...stroke}>
<path d="M8 42h32" />
<path d="M15 42V10l9-5 9 5v32" />
<path d="M19 15h10M19 21h10M19 27h10M19 33h10" />
</g>
}
{name === 'mall' &&
<g {...stroke}>
<path d="M6 42h36" />
<path d="M9 42V20h30v22" />
<path d="M9 20l6-10h18l6 10" />
<path d="M19 42V30h10v12" />
</g>
}
{name === 'villa' &&
<g {...stroke}>
<path d="M5 42h38" />
<path d="M9 42V22l15-10 15 10v20" />
<path d="M18 42V30h12v12" />
<path d="M9 26h30" />
</g>
}
{name === 'renew' &&
<g {...stroke}>
<path d="M10 42h28V20L24 10 10 20z" />
<path d="M31 6a9 9 0 1 0 6 15" />
<path d="M38 5v7h-7" />
</g>
}
{name === 'manage' &&
<g {...stroke}>
<rect x="8" y="8" width="32" height="32" rx="2" />
<path d="M8 17h32" />
<path d="M14 25h8M14 32h14M27 24l4 4 7-8" />
</g>
}
{name === 'invest' &&
<g {...stroke}>
<path d="M7 40h34" />
<path d="M12 40V26M22 40V18M32 40V30" />
<path d="M9 16l10-6 8 5 12-9" />
<path d="M39 6h-7M39 6v7" />
</g>
}
{name === 'landscape' &&
<g {...stroke}>
<path d="M5 40h38" />
<path d="M14 40V24M14 24c-4 0-7-3-7-6s3-5 7-5 7 2 7 5-3 6-7 6z" />
<path d="M31 40V22l7-7 4 5v20" />
<path d="M24 40c2-4 5-6 9-6" />
</g>
}
</svg>);
}
@@ -0,0 +1,267 @@
import { useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import {
BoldIcon,
ItalicIcon,
Heading2Icon,
Heading3Icon,
ImagePlusIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
Redo2Icon,
StrikethroughIcon,
UnderlineIcon,
Undo2Icon,
Loader2Icon,
} from 'lucide-react'
import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient'
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [
StarterKit.configure({
link: false,
heading: { levels: [2, 3] },
}),
Image.configure({ allowBase64: false }),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
}),
]
type ArticleEditorProps = {
doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void
}
function ToolButton({
onClick,
active = false,
label,
children,
}: {
onClick: () => void
active?: boolean
label: string
children: React.ReactNode
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
active
? 'border-copper/50 bg-copper/15 text-copper'
: 'border-transparent text-ivory/60 hover:border-ivory/15 hover:text-copper'
}`}
>
{children}
</button>
)
}
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
/**
* ویرایشگر WYSIWYG مقاله (TipTap) — راست‌به‌چپ، با آپلود مستقیم تصویر به MinIO
* (انتخاب فایل، چسباندن یا کشیدن تصویر داخل متن).
*/
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const editor = useEditor({
extensions: ARTICLE_EXTENSIONS,
content: doc && Object.keys(doc).length ? doc : undefined,
immediatelyRender: false,
editorProps: {
attributes: {
dir: 'rtl',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true',
},
handlePaste: (_event_ignored, event) => {
const file = event.clipboardData?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
return true
}
return false
},
handleDrop: (_view, event, _slice, moved) => {
if (moved) return false
const file = event.dataTransfer?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
return true
}
return false
},
},
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
})
async function uploadAndInsert(file: File) {
if (!editor) return
setUploading(true)
await runSave(async () => {
const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
})
setUploading(false)
}
const setLink = () => {
if (!editor) return
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
return
}
void Swal.fire({
title: 'نشانی لینک',
input: 'text',
inputValue: 'https://',
background: '#1e1a16',
color: '#F5F3EF',
showCancelButton: true,
confirmButtonText: 'ثبت',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
title: 'font-pinar font-bold text-ivory',
input: 'font-pinar',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
},
}).then((result) => {
if (!result.isConfirmed || !result.value) return
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
})
}
if (!editor) return null
return (
<div className="border border-ivory/15 bg-char">
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
<ToolButton
label="بولد"
active={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<BoldIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="ایتالیک"
active={editor.isActive('italic')}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<ItalicIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="زیرخط"
active={editor.isActive('underline')}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<UnderlineIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="خط‌خورده"
active={editor.isActive('strike')}
onClick={() => editor.chain().focus().toggleStrike().run()}
>
<StrikethroughIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton
label="تیتر میانی"
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر کوچک"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3Icon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton
label="لیست نقطه‌ای"
active={editor.isActive('bulletList')}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<ListIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="لیست شماره‌ای"
active={editor.isActive('orderedList')}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrderedIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="نقل‌قول"
active={editor.isActive('blockquote')}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<QuoteIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
<Link2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
</ToolButton>
<Divider />
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
<Undo2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
<Redo2Icon className="h-4 w-4" />
</ToolButton>
</div>
<EditorContent editor={editor} />
<input
ref={fileRef}
type="file"
data-editor-file-input
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
/>
</div>
)
}
@@ -0,0 +1,81 @@
import { useRef, useState } from 'react'
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
type ImageFieldProps = {
label: string
value: string
onSave: (url: string) => Promise<boolean>
}
/**
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش استفاده می‌شود.
* پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود.
*/
export function ImageField({ label, value, onSave }: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
if (!isAuthenticated || !isEditMode) return null
const pick = async (file: File | undefined) => {
if (!file) return
setUploading(true)
try {
await runSave(async () => {
const { url } = await adminApi.upload(file)
const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد')
})
} finally {
setUploading(false)
}
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
<div className="mt-2 flex items-center gap-3">
{value ? (
<img
src={value}
alt={label}
className="h-16 w-24 border border-ivory/15 object-cover"
/>
) : (
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
—
</div>
)}
<div className="min-w-0 flex-1">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlusIcon className="h-3.5 w-3.5" />
)}
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
</button>
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
</div>
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
void pick(e.target.files?.[0])
e.target.value = ''
}}
/>
</div>
)
}
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, ElementType, KeyboardEvent } from 'react'
import { useAdmin } from '../../admin/AdminContext'
type InlineEditProps = {
value: string
/** ذخیره async — در صورت خطا، متن به مقدار قبلی برمی‌گردد */
onSave: (value: string) => Promise<boolean> | boolean | void
as?: ElementType
/** چندخطی: Enter خط جدید می‌گذارد و فقط blur ذخیره می‌کند */
multiline?: boolean
className?: string
style?: CSSProperties
'aria-label'?: string
}
/**
* ویرایش درجا متن — در حالت عادی دقیقاً همان HTML قبلی را رندر می‌کند
* (بدون هیچ تغییر چیدمان) و فقط در Edit Mode برای ادمین قابل ویرایش می‌شود.
*/
export function InlineEdit({
value,
onSave,
as: Tag = 'span',
multiline = false,
className = '',
style,
...rest
}: InlineEditProps) {
const { isAuthenticated, isEditMode } = useAdmin()
const editing = isAuthenticated && isEditMode
const ref = useRef<HTMLElement>(null)
const [focused, setFocused] = useState(false)
// همگام‌سازی متن فقط وقتی المان در حال ویرایش نیست (پرش نشانگر رخ ندهد)
useEffect(() => {
const el = ref.current
if (el && !focused && el.innerText !== value) el.innerText = value
}, [value, focused])
if (!editing) {
return (
<Tag className={className} style={style} {...rest}>
{value}
</Tag>
)
}
const commit = async () => {
const el = ref.current
if (!el) return
setFocused(false)
const next = el.innerText.replace(/\u00a0/g, ' ')
if (next === value) return
el.innerText = value // بازگشت فوری به مقدار فعلی؛ در صورت موفقیت، به‌روزرسانی خوش‌بینانه متن تازه می‌آید
await onSave(next)
}
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!multiline && e.key === 'Enter') {
e.preventDefault()
;(e.currentTarget as HTMLElement).blur()
}
if (e.key === 'Escape') {
if (ref.current) ref.current.innerText = value
;(e.currentTarget as HTMLElement).blur()
}
}
return (
<Tag
ref={ref as never}
contentEditable
suppressContentEditableWarning
spellCheck={false}
data-inline-edit
aria-label={rest['aria-label']}
className={`${className} cursor-text rounded-sm transition-[outline-color,background-color] duration-150 outline-1 outline-dashed outline-transparent hover:bg-copper/5 hover:outline-copper/40 focus:bg-copper/5 focus:outline-copper focus:outline-solid`}
style={style}
onFocus={() => setFocused(true)}
onBlur={commit}
onKeyDown={onKeyDown}
>
{value}
</Tag>
)
}
@@ -0,0 +1,208 @@
import { useMemo } from 'react'
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
import type { ArticleBodyDoc } from '../../data/site'
export type SeoInput = {
title: string
slug: string
excerpt: string
body: ArticleBodyDoc | string[]
keyphrase: string
}
type Status = 'good' | 'ok' | 'bad'
type Check = { label: string; status: Status; hint: string }
// ---------- استخراج متن/ساختار از سند TipTap ----------
function docToText(body: SeoInput['body']): string {
if (Array.isArray(body)) return body.join(' ')
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && n.text) out.push(n.text)
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out.join(' ')
}
function docHeadings(body: SeoInput['body']): string[] {
if (Array.isArray(body)) return []
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
if (n.type === 'heading' && n.attrs?.level === 2) {
const texts: string[] = []
const collect = (inner: unknown) => {
if (!inner || typeof inner !== 'object') return
const t = inner as { type?: string; text?: string; content?: unknown[] }
if (t.type === 'text' && t.text) texts.push(t.text)
for (const c of t.content ?? []) collect(c)
}
collect(n)
out.push(texts.join(''))
}
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
if (Array.isArray(body)) return []
const out: { src: string; alt: string }[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function docLinks(body: SeoInput['body']): string[] {
if (Array.isArray(body)) return []
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function firstParagraph(body: SeoInput['body']): string {
if (Array.isArray(body)) return body[0] ?? ''
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
(c) => c.type === 'paragraph',
)
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
}
// ---------- محاسبه بررسی‌ها ----------
export function useSeoChecks(input: SeoInput): Check[] {
return useMemo(() => {
const text = docToText(input.body)
const words = text.split(/\s+/).filter(Boolean)
const kp = input.keyphrase.trim()
const has = (s: string, needle: string) => s.includes(needle)
const checks: Check[] = []
// عنوان
const tLen = input.title.trim().length
if (tLen === 0) checks.push({ label: 'عنوان مقاله', status: 'bad', hint: 'برای مقاله عنوان بنویسید.' })
else if (tLen >= 20 && tLen <= 60) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'good', hint: 'طول عنوان مناسب است.' })
else if (tLen >= 10 && tLen <= 70) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی عنوان بین ۲۰ تا ۶۰ نویسه است.' })
else checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'bad', hint: 'طول عنوان خارج از بازه ۱۰ تا ۷۰ نویسه است.' })
// کلمه کلیدی هدف
if (!kp) {
checks.push({ label: 'کلمه کلیدی هدف', status: 'ok', hint: 'کلمه کلیدی هدف را بنویسید تا حضور آن در متن بررسی شود.' })
} else {
checks.push(
has(input.title, kp)
? { label: 'کلمه کلیدی در عنوان', status: 'good', hint: 'کلمه کلیدی در عنوان هست.' }
: { label: 'کلمه کلیدی در عنوان', status: 'bad', hint: `کلمه «${kp}» در عنوان نیست.` },
)
const firstP = firstParagraph(input.body)
checks.push(
has(firstP, kp)
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
)
const h2s = docHeadings(input.body)
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
else
checks.push(
h2s.some((h) => has(h, kp))
? { label: 'کلمه کلیدی در تیترها', status: 'good', hint: 'کلمه کلیدی در یکی از تیترها هست.' }
: { label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'کلمه کلیدی در هیچ تیتر H2 نیست.' },
)
}
// چکیده (توضیح متا)
const eLen = input.excerpt.trim().length
if (eLen === 0) checks.push({ label: 'چکیده (توضیح متا)', status: 'bad', hint: 'چکیده بنویسید — روی کارت مقاله و توضیح متای گوگل نمایش داده می‌شود.' })
else if (eLen >= 70 && eLen <= 160) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'good', hint: 'طول چکیده برای توضیح متا ایده‌آل است.' })
else if (eLen >= 40 && eLen <= 200) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی چکیده بین ۷۰ تا ۱۶۰ نویسه است.' })
else checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'bad', hint: 'چکیده خیلی کوتاه یا بلندتر از ۲۰۰ نویسه است.' })
// طول متن
if (words.length >= 150) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'good', hint: 'طول متن برای محتوای ارزشمند کافی است.' })
else if (words.length >= 80) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'ok', hint: 'متن کمی کوتاه است — حداقل ۱۵۰ واژه پیشنهاد می‌شود.' })
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
// تیتر H2
const h2s = docHeadings(input.body)
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخش‌بندی کنید.' })
// تصاویر
const imgs = docImages(input.body)
if (imgs.length === 0)
checks.push({ label: 'تصاویر', status: 'ok', hint: 'تصویری داخل متن نیست — افزودن تصویر به جذابیت مقاله کمک می‌کند.' })
else if (imgs.every((i) => i.alt.trim()))
checks.push({ label: `تصاویر (${imgs.length} تصویر با متن جایگزین)`, status: 'good', hint: 'همه تصاویر متن جایگزین دارند.' })
else
checks.push({ label: 'تصاویر', status: 'bad', hint: 'برای همه تصاویر، متن جایگزین (alt) بنویسید.' })
// لینک‌ها
const links = docLinks(input.body)
if (links.length > 0) checks.push({ label: `پیوندها (${links.length} لینک)`, status: 'good', hint: 'متن پیوند دارد.' })
else checks.push({ label: 'پیوندها', status: 'ok', hint: 'یک لینک مرتبط (داخلی به پروژه/خدمات یا خارجی) اضافه کنید.' })
// نشانی
if (/^article-\d+$/.test(input.slug))
checks.push({ label: 'نشانی (slug)', status: 'bad', hint: 'نشانی پیش‌فرض را به یک نشانی خوانا (انگلیسی) تغییر دهید.' })
else if (input.slug.length > 96)
checks.push({ label: 'نشانی (slug)', status: 'ok', hint: 'نشانی خیلی بلند است — کوتاهش کنید.' })
else
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
return checks
}, [input])
}
const STATUS_STYLE = {
good: { icon: CheckCircle2Icon, color: 'text-emerald-400' },
ok: { icon: AlertCircleIcon, color: 'text-amber-400' },
bad: { icon: XCircleIcon, color: 'text-red-400' },
} as const
/** پنل بررسی سئو — فهرست سبز/نارنجی/قرمز به سبک یوست، سازگار با فارسی */
export function SeoChecklist({ input }: { input: SeoInput }) {
const checks = useSeoChecks(input)
const good = checks.filter((c) => c.status === 'good').length
return (
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="بررسی سئو">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-copper">بررسی سئو</h2>
<span className="text-xs font-light text-ivory/50">
{good} از {checks.length} مورد سبز
</span>
</div>
<ul className="mt-4 grid gap-2.5">
{checks.map((c) => {
const S = STATUS_STYLE[c.status]
return (
<li key={c.label} className="flex items-start gap-3">
<S.icon className={`mt-1 h-4 w-4 shrink-0 ${S.color}`} aria-hidden="true" />
<div>
<p className="text-sm font-light text-ivory/85">{c.label}</p>
<p className="text-xs font-light leading-6 text-ivory/45">{c.hint}</p>
</div>
</li>
)
})}
</ul>
</section>
)
}
+67
View File
@@ -0,0 +1,67 @@
import React from 'react';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api';
import { useFieldSaver } from '../../admin/AdminContext';
import { InlineEdit } from '../admin/InlineEdit';
import { AddItemButton, EditableItem } from '../../admin/Editable';
export function Articles() {
const { articles } = useSite();
const heading = useSectionHeading('articles');
const save = useFieldSaver();
// پیش‌نویس‌ها فقط در صفحه وبلاگ (برای ادمین) دیده می‌شوند، نه در خانه
const published = articles.filter((a) => a.status !== 'draft');
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id);
if (h) (h as Record<string, unknown>)[field] = value;
});
return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right">
<p className="text-sm font-light text-copper">
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
</p>
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
</p>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-3">
{published.slice(0, 3).map((article, i) =>
<Reveal key={article.id} delay={0.06 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<ArticleCard article={article} />
</EditableItem>
</Reveal>
)}
</div>
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</div>
</Reveal>
)}
<div className="mt-6">
<AddItemButton resource="articles" />
</div>
</div>
</section>);
}
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { MoveHorizontalIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { SingletonPill } from '../../admin/Editable'
export function BeforeAfter() {
const { before_after } = useSite()
const containerRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState(52)
const dragging = useRef(false)
const setFromClientX = useCallback((clientX: number) => {
const el = containerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const ratio = ((clientX - rect.left) / rect.width) * 100
setPosition(Math.min(98, Math.max(2, ratio)))
}, [])
useEffect(() => {
const move = (e: MouseEvent | TouchEvent) => {
if (!dragging.current) return
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
setFromClientX(clientX)
}
const stop = () => {
dragging.current = false
}
window.addEventListener('mousemove', move)
window.addEventListener('touchmove', move)
window.addEventListener('mouseup', stop)
window.addEventListener('touchend', stop)
return () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('touchmove', move)
window.removeEventListener('mouseup', stop)
window.removeEventListener('touchend', stop)
}
}, [setFromClientX])
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') setPosition((p) => Math.max(2, p - 4))
if (e.key === 'ArrowRight') setPosition((p) => Math.min(98, p + 4))
}
return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="ba-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title={before_after.title}
lead={before_after.lead}
align="center"
/>
<div className="flex justify-start">
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسب‌های قبل/بعد" />
</div>
<Reveal delay={0.1}>
<div
ref={containerRef}
className="relative mx-auto mt-14 aspect-[4/3] w-full max-w-4xl select-none overflow-hidden rounded-xl border border-ivory/10 shadow-2xl md:aspect-[16/10]"
onMouseDown={(e) => {
dragging.current = true
setFromClientX(e.clientX)
}}
onTouchStart={(e) => {
dragging.current = true
setFromClientX(e.touches[0].clientX)
}}
>
<img
src={before_after.before}
alt={before_after.before_label}
className="absolute inset-0 h-full w-full object-cover object-center"
draggable={false}
/>
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(0 0 0 ${position}%)` }} aria-hidden="true">
<img
src={before_after.after}
alt=""
className="absolute inset-0 h-full w-full object-cover object-center"
draggable={false}
/>
</div>
<span className="pointer-events-none absolute left-6 top-6 z-30 rounded-md bg-ink/85 px-4 py-2 text-xs font-light text-ivory backdrop-blur-md border border-ivory/10 shadow-lg">
{before_after.before_label}
</span>
<span className="pointer-events-none absolute right-6 top-6 z-30 rounded-md bg-ink/85 px-4 py-2 text-xs font-light text-copper backdrop-blur-md border border-copper/30 shadow-lg">
{before_after.after_label}
</span>
<div className="pointer-events-none absolute inset-y-0 z-30" style={{ left: `${position}%` }}>
<div className="h-full w-[2px] bg-copper shadow-[0_0_12px_rgba(213,135,76,0.9)]" />
</div>
<button
type="button"
role="slider"
aria-label="مقایسه کانسپت و پروژه ساخته‌شده"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(position)}
onKeyDown={onKeyDown}
onMouseDown={() => {
dragging.current = true
}}
onTouchStart={() => {
dragging.current = true
}}
className="absolute top-1/2 z-40 flex h-14 w-14 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full bg-copper text-char shadow-2xl transition-transform duration-200 ease-lux hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
style={{ left: `${position}%` }}
>
<MoveHorizontalIcon className="h-5 w-5" />
</button>
</div>
</Reveal>
</div>
</section>
)
}
+81
View File
@@ -0,0 +1,81 @@
import React, { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { AddItemButton, EditableItem } from '../../admin/Editable'
type FaqProps = {
hideHeading?: boolean;
}
export function Faq({ hideHeading = false }: FaqProps) {
const { faqs } = useSite()
const [open, setOpen] = useState<number | null>(0)
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
{!hideHeading && (
<div className="lg:col-span-4">
<SectionHeading
title="سوالات متداول"
lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."
headingKey="faq"
/>
</div>
)}
<div className={hideHeading ? "" : "lg:col-span-8"}>
<ul>
{faqs.map((faq, i) => {
const isOpen = open === i
return (
<Reveal key={faq.id} delay={0.04 * i}>
<EditableItem resource="faqs" item={faq}>
<li className="border-b border-ivory/10">
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
isOpen ? 'rotate-45' : ''
}`}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
</motion.div>
)}
</AnimatePresence>
</li>
</EditableItem>
</Reveal>
)
})}
</ul>
<div className="mt-8">
<AddItemButton resource="faqs" />
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,125 @@
import React, { useState } from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site'
export function FeaturedProjects() {
const { projects, status_labels } = useSite()
const heading = useSectionHeading('featured_projects')
const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key)
if (!label) return
void save('status-labels', label.id, { label: value }, (d) => {
const s = d.status_labels.find((x) => x.id === label.id)
if (s) s.label = value
})
}
const FILTERS: { key: ProjectStatus; label: string }[] = [
{ key: 'done', label: useStatusLabel('done') },
{ key: 'progress', label: useStatusLabel('progress') },
]
const [status, setStatus] = useState<ProjectStatus>('done')
const list = projects.filter((p) => p.status === status).slice(0, 3)
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
{/* دکمه‌های فیلتر کپسولی شیشه‌ای مات */}
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter) => {
const isSelected = status === filter.key
return (
<button
key={filter.key}
type="button"
role="tab"
aria-selected={isSelected}
onClick={() => setStatus(filter.key)}
className={`group relative flex items-center justify-center overflow-hidden rounded-full px-7 py-3 text-sm transition-all duration-300 ease-out active:scale-95 cursor-pointer select-none ${
isSelected
? 'border border-copper bg-copper font-bold text-char shadow-[0_0_25px_rgba(197,143,92,0.45),inset_0_1px_1px_rgba(255,255,255,0.4)]'
: 'border border-white/15 bg-white/[0.04] font-light text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)] hover:border-copper/60 hover:bg-white/[0.08] hover:text-ivory hover:shadow-[0_0_20px_rgba(197,143,92,0.2)]'
}`}
>
{/* خط بازتاب نوری لبه شیشه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
<span className="relative z-10 transition-colors duration-300 ease-out">
<InlineEdit
value={filter.label}
onSave={(v) => saveStatusLabel(filter.key, v)}
/>
</span>
</button>
)
})}
</div>
<div className="mt-10 min-h-[420px]">
<AnimatePresence mode="wait">
<motion.div
key={status}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="grid gap-6 lg:grid-cols-3"
>
{list.map((project, i) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full"
>
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} />
</EditableItem>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
{/* دکمه مشاهده همه پروژه‌ها: کپسولی شیشه‌ای با گوی آیکون متحرک */}
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink
to={heading.cta_href}
variant="outline"
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
>
<InlineEdit
value={heading.cta_label}
onSave={(v) =>
save('section-headings', heading.id, { cta_label: v }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id)
if (h) h.cta_label = v
})
}
/>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 group-hover:-translate-x-1 group-hover:bg-copper group-hover:text-char">
<ArrowLeftIcon className="h-3.5 w-3.5" />
</span>
</ButtonLink>
</div>
</Reveal>
)}
</div>
</section>
)
}
+123
View File
@@ -0,0 +1,123 @@
import React, { useCallback, useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Gallery() {
const { gallery } = useSite()
const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), [])
const step = useCallback((dir: number) => {
setActive((prev) => (prev === null ? prev : (prev + dir + gallery.length) % gallery.length))
}, [gallery.length])
useEffect(() => {
if (active === null) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
if (e.key === 'ArrowRight') step(-1)
if (e.key === 'ArrowLeft') step(1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [active, close, step])
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title="گالری تصاویر"
lead="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش."
headingKey="gallery"
align="center"
/>
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
{gallery.map((image, i) => (
<Reveal key={image.id} delay={0.04 * i}>
<EditableItem resource="gallery" item={image}>
<button
type="button"
onClick={() => setActive(i)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`بزرگ‌نمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
>
<img
src={image.src}
alt={image.alt}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="gallery" />
</div>
</div>
<AnimatePresence>
{active !== null && gallery[active] && (
<motion.div
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
role="dialog"
aria-modal="true"
aria-label="نمایش تصویر"
onClick={close}
>
<button
type="button"
onClick={close}
className="absolute left-8 top-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
aria-label="بستن"
>
<XIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(-1)
}}
className="absolute right-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:right-10"
aria-label="تصویر قبلی"
>
<ChevronRightIcon className="h-6 w-6" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(1)
}}
className="absolute left-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10"
aria-label="تصویر بعدی"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
<motion.img
key={active}
src={gallery[active].src}
alt=""
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</section>
)
}
+151
View File
@@ -0,0 +1,151 @@
import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button'
import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
export function Hero() {
const { brand, hero } = useSite()
const save = useFieldSaver()
const { scrollY } = useScroll()
const imageY = useTransform(scrollY, [0, 700], [0, 110])
const contentY = useTransform(scrollY, [0, 700], [0, -55])
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
const saveHero = (field: string, value: unknown) =>
save('hero', hero.id, { [field]: value }, (d) => {
;(d.hero as unknown as Record<string, unknown>)[field] = value
})
return (
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
{/* تصویر پس‌زمینه */}
<motion.img
src={brand.hero}
alt="نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش"
style={{ y: imageY }}
className="absolute inset-0 h-[114%] w-full object-cover"
/>
<motion.div className="absolute inset-0 bg-ink" style={{ opacity: overlay }} aria-hidden="true" />
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(to top, #27221c 5%, rgba(39,34,28,0.2) 60%, rgba(39,34,28,0.6) 100%)',
}}
aria-hidden="true"
/>
{/* محتوای اصلی هیرو */}
<motion.div
style={{ y: contentY }}
className="relative mx-auto flex h-full max-w-site flex-col justify-center px-6 lg:px-10"
>
<div className="w-full max-w-[700px] lg:max-w-[58%]">
{/* کیکر بالای تیتر */}
<motion.p
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
className="text-sm font-light text-copper tracking-wide"
>
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
</motion.p>
{/* تیتر اصلی */}
<motion.h1
initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
>
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
</motion.h1>
{/* زیرتیتر */}
<motion.p
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
>
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
</motion.p>
{/* داک دکمه‌ها: استایل کپسولی، شیشه‌ای و دایره‌ای */}
<motion.div
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4"
>
{/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */}
<ButtonLink
to={hero.cta_primary_href}
variant="copper"
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
>
<span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 group-hover:-translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
</span>
</ButtonLink>
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
<ButtonLink
to={hero.cta_secondary_href}
variant="outline"
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
>
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
</ButtonLink>
</motion.div>
</div>
{/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */}
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:left-10"
>
<p className="text-3xl font-bold text-copper font-mono">
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
</p>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
</p>
</motion.div>
</motion.div>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
<a
href="#story"
aria-label="رفتن به بخش بعدی"
className="group absolute bottom-7 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/60 transition-colors duration-300 hover:text-copper"
>
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
<InlineEdit value={hero.scroll_label} onSave={(v) => saveHero('scroll_label', v)} />
</span>
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-ivory/20 bg-char/40 backdrop-blur-md transition-all duration-300 group-hover:border-copper group-hover:bg-char/70 group-hover:shadow-lg group-hover:shadow-copper/20">
<ChevronDownIcon className="h-4 w-4 animate-bounce text-ivory group-hover:text-copper" />
</div>
</a>
{/* کنترل‌های ادمین */}
<div className="absolute left-6 top-24 z-20 flex flex-col gap-2 lg:left-10">
<SingletonPill resource="hero" label="تنظیمات هیرو (واژه مسی، دکمه‌ها)" />
<SingletonPill
resource="seo-meta"
label="عنوان و توضیح سئوی صفحه اصلی"
select={(d) => d.seo_meta.find((s) => s.page_key === 'home')}
/>
</div>
</section>
)
}
+161
View File
@@ -0,0 +1,161 @@
import React, { useState } from 'react'
import { CheckIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from '../Reveal'
import { Button } from '../Button'
import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
import { API_URL } from '../../config'
export function Newsletter() {
const { newsletter } = useSite()
const save = useFieldSaver()
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
const [sending, setSending] = useState(false)
const saveContent = (field: string, value: string) =>
save('newsletter', newsletter.id, { [field]: value }, (d) => {
;(d.newsletter as unknown as Record<string, unknown>)[field] = value
})
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
title: title,
text: text,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-md bg-ink/70',
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
},
showClass: {
popup: 'swal2-show',
backdrop: 'swal2-backdrop-show',
},
hideClass: {
popup: 'swal2-hide',
backdrop: 'swal2-backdrop-hide',
}
})
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (sending) return
const emailValue = email.trim()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailValue) {
setStatus('error')
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
return
}
if (!emailRegex.test(emailValue)) {
setStatus('error')
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
return
}
setSending(true)
try {
const res = await fetch(`${API_URL}/api/newsletter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailValue }),
})
if (!res.ok) {
setStatus('error')
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در عضویت', detail)
return
}
setStatus('done')
setEmail('')
} catch {
setStatus('error')
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally {
setSending(false)
}
}
return (
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
<SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" />
{/* هاله نور پس‌زمینه برای عمق بصری */}
<div className="pointer-events-none absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-black/15 blur-3xl" />
<div className="relative mx-auto grid max-w-site items-center gap-10 px-6 lg:grid-cols-2 lg:px-10">
<Reveal>
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
<InlineEdit value={newsletter.title} onSave={(v) => saveContent('title', v)} />
</h2>
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/80">
<InlineEdit multiline value={newsletter.lead} onSave={(v) => saveContent('lead', v)} />
</p>
</Reveal>
<Reveal delay={0.08}>
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
<div className="flex-1">
<label htmlFor="newsletter-email" className="sr-only">
{newsletter.placeholder}
</label>
<input
id="newsletter-email"
type="email"
dir="ltr"
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (status !== 'idle') setStatus('idle')
}}
placeholder={newsletter.placeholder}
className="w-full rounded-full border border-black/15 bg-char/90 px-6 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 shadow-[inset_0_2px_4px_rgba(0,0,0,0.35)] backdrop-blur-xl transition-all duration-300 ease-lux focus:border-ivory focus:bg-char focus:shadow-[0_0_20px_rgba(255,255,255,0.25)] focus:outline-none"
aria-invalid={status === 'error'}
/>
{status === 'done' && (
<p className="mt-2.5 flex items-center gap-1.5 px-3 text-xs font-medium text-char" role="status">
<CheckIcon className="h-4 w-4" />
{newsletter.success_text}
</p>
)}
</div>
<Button
type="submit"
variant="dark"
disabled={sending}
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
>
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
<span>
{sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)}
</span>
</Button>
</form>
</Reveal>
</div>
</section>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
import { Reveal } from '../Reveal'
import { ServiceIcon } from '../ServiceIcon'
import { useSectionHeading, useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Services() {
const { services } = useSite()
const heading = useSectionHeading('services')
const save = useFieldSaver()
const saveHeading = (field: 'title' | 'lead', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id)
if (h) h[field] = value
})
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right">
<h2 id="services-title" className="text-section font-bold text-ivory">
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
</p>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{services.map((service, i) => (
<Reveal key={service.id} delay={0.04 * i} className="h-full">
<EditableItem resource="services" item={service} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<ServiceIcon
name={service.icon}
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
/>
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{service.title}
</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="services" />
</div>
</div>
</section>
)
}
+70
View File
@@ -0,0 +1,70 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion';
import { useSite } from '../../api';
import { useFieldSaver } from '../../admin/AdminContext';
import { AddItemButton, EditableItem } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit';
function toFa(n: number) {
return n.toLocaleString('fa-IR');
}
function Counter({ value }: {value: number;}) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: '-60px' });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!inView) return;
let frame = 0;
const total = 60;
const id = window.setInterval(() => {
frame += 1;
const progress = 1 - Math.pow(1 - frame / total, 3);
setDisplay(Math.round(value * progress));
if (frame >= total) window.clearInterval(id);
}, 20);
return () => window.clearInterval(id);
}, [inView, value]);
return <span ref={ref}>{toFa(display)}</span>;
}
export function Stats() {
const { stats } = useSite();
const save = useFieldSaver();
return (
<section className="bg-char py-16" aria-label="آمار و ارقام">
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
{stats.map((stat) =>
<EditableItem key={stat.id} resource="stats" item={stat}>
<div className="text-center">
<p className="text-4xl font-bold text-copper md:text-5xl">
<Counter value={stat.value} />
<InlineEdit
value={stat.suffix}
onSave={(v) => save('stats', stat.id, { suffix: v }, (d) => {
const s = d.stats.find((x) => x.id === stat.id);
if (s) s.suffix = v;
})}
className="mr-1 text-2xl"
/>
</p>
<p className="mt-3 text-sm font-light text-ivory/55">
<InlineEdit
value={stat.label}
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
const s = d.stats.find((x) => x.id === stat.id);
if (s) s.label = v;
})}
/>
</p>
</div>
</EditableItem>
)}
<AddItemButton resource="stats" />
</div>
</section>);
}
+121
View File
@@ -0,0 +1,121 @@
import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
type StoryProps = {
showLink?: boolean;
showLabel?: boolean;
showQuote?: boolean;
alignTop?: boolean;
children?: React.ReactNode;
}
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
const { story } = useSite()
const save = useFieldSaver()
const saveStory = (field: string, value: unknown) =>
save('story', story.id, { [field]: value }, (d) => {
;(d.story as unknown as Record<string, unknown>)[field] = value
})
const saveParagraph = (index: number, value: string) => {
const paragraphs = [...story.paragraphs]
paragraphs[index] = value
void saveStory('paragraphs', paragraphs)
}
return (
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
<div>
{showLabel && (
<Reveal>
<h2 id="story-title" className="text-section font-bold text-ivory">
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} />
</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
)}
<Reveal delay={0.08}>
{story.paragraphs.map((paragraph, i) => (
<p key={i} className={`text-base font-light leading-9 text-ivory/65 ${i === 0 ? 'mt-8' : 'mt-5'}`}>
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
</p>
))}
</Reveal>
<Reveal delay={0.16}>
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
<div>
<p className="text-2xl font-bold text-copper">
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} />
</p>
<p className="mt-2 text-sm font-light text-ivory/55">
<InlineEdit value={story.stat1_label} onSave={(v) => saveStory('stat1_label', v)} />
</p>
</div>
<div>
<p className="text-2xl font-bold text-copper">
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} />
</p>
<p className="mt-2 text-sm font-light text-ivory/55">
<InlineEdit value={story.stat2_label} onSave={(v) => saveStory('stat2_label', v)} />
</p>
</div>
</div>
</Reveal>
{showQuote && story.quote && (
<Reveal delay={0.2}>
<div className="mt-12 border-r-2 border-copper bg-char/40 p-6">
<p className="text-sm font-light leading-8 text-ivory/80">
<InlineEdit multiline value={story.quote} onSave={(v) => saveStory('quote', v)} />
</p>
<p className="mt-5 text-sm font-bold text-copper">
<InlineEdit value={story.quote_author} onSave={(v) => saveStory('quote_author', v)} />
</p>
</div>
</Reveal>
)}
{showLink && (
<Reveal delay={0.22}>
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</Reveal>
)}
{children && (
<Reveal delay={0.24}>
{children}
</Reveal>
)}
</div>
<div className="relative grid grid-cols-2 gap-4">
<SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 right-0" />
{story.bento.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden">
<img
src={item.src}
alt={item.alt}
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105"
/>
</div>
</Reveal>
))}
</div>
</div>
</section>
)
}
+41
View File
@@ -0,0 +1,41 @@
import React from 'react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Team() {
const { team } = useSite()
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title="تیم ما"
lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."
headingKey="team"
align="center"
/>
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{team.map((member, i) => (
<Reveal key={member.id} delay={0.06 * i} className="h-full">
<EditableItem resource="team" item={member} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
{member.name.trim().charAt(0)}
</div>
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
{member.bio}
</p>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="team" />
</div>
</div>
</section>
)
}
@@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
import { SectionHeading } from '../SectionHeading';
import { useSite } from '../../api';
import { EditableItem } from '../../admin/Editable';
export function Testimonials() {
const { testimonials } = useSite();
const [index, setIndex] = useState(0);
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
if (!current) return null;
const go = (dir: number) => {
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
};
return (
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading headingKey="testimonials" align="center" />
<div className="relative mx-auto mt-14 max-w-3xl text-center">
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
<AnimatePresence mode="wait">
<motion.blockquote
key={current.id}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
<EditableItem resource="testimonials" item={current}>
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
<footer className="mt-8">
<p className="text-base font-bold text-copper">{current.name}</p>
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
</footer>
</EditableItem>
</motion.blockquote>
</AnimatePresence>
<div className="mt-10 flex items-center justify-center gap-4">
<button
type="button"
onClick={() => go(1)}
aria-label="نظر قبلی"
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<ChevronRightIcon className="h-5 w-5" />
</button>
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
{testimonials.map((t, i) =>
<button
key={t.id}
type="button"
role="tab"
aria-selected={i === index}
aria-label={`نظر ${t.name}`}
onClick={() => setIndex(i)}
className={`h-1.5 transition-[width,background-color] duration-200 ease-lux ${
i === index ? 'w-8 bg-copper' : 'w-3 bg-ivory/25'}`
} />
)}
</div>
<button
type="button"
onClick={() => go(-1)}
aria-label="نظر بعدی"
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</section>);
}
+92
View File
@@ -0,0 +1,92 @@
import React, { useRef } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSite, type TimelineItem } from '../../api'
import { SectionHeading } from '../SectionHeading'
import { AddItemButton, EditableItem } from '../../admin/Editable'
const ease = [0.23, 1, 0.32, 1] as const
type Milestone = TimelineItem
function Item({ item, index }: { item: Milestone; index: number }) {
const ref = useRef<HTMLLIElement>(null)
const active = useInView(ref, { once: true, amount: 0.6 })
const onRight = index % 2 === 0
return (
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
<span
aria-hidden="true"
className={`absolute right-[-5px] top-6 z-10 h-2.5 w-2.5 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
active
? 'bg-copper shadow-[0_0_0_5px_rgba(213,135,76,0.22)]'
: 'bg-ivory/25'
}`}
/>
<motion.div
className={`pr-8 md:pr-0 ${
onRight ? 'md:col-start-1 md:pl-16 md:text-left' : 'md:col-start-2 md:pr-16'
}`}
initial={{ opacity: 0, y: 24 }}
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.55, ease }}
>
<EditableItem resource="timeline" item={item}>
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
<span className="text-sm font-light text-copper">{item.year}</span>
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
</div>
</EditableItem>
</motion.div>
</li>
)
}
export function Timeline() {
const { timeline } = useSite()
const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start 70%', 'end 60%'],
})
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
return (
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
<div className="mb-16">
<SectionHeading
title="مسیر ما"
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."
headingKey="timeline"
align="center"
/>
</div>
<div ref={ref} className="relative mx-auto max-w-4xl">
<span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px bg-ivory/12 md:right-1/2"
/>
<motion.span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px origin-top bg-copper md:right-1/2"
style={{ scaleY }}
/>
<ol className="flex flex-col gap-12 md:gap-16">
{timeline.map((item, i) => (
<Item key={item.id} item={item} index={i} />
))}
</ol>
<div className="mt-12">
<AddItemButton resource="timeline" />
</div>
</div>
</div>
</section>
)
}
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { Reveal } from '../Reveal';
import { SectionHeading } from '../SectionHeading';
import { useSectionHeading, useSite } from '../../api';
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit';
import { useFieldSaver } from '../../admin/AdminContext';
export function WhyUs() {
const { why_us } = useSite();
const heading = useSectionHeading('why_us');
const save = useFieldSaver();
const saveItem = (id: number, changes: Record<string, unknown>) =>
save('why-us', id, changes, (d) => {
const it = d.why_us.find((x) => x.id === id);
if (it) Object.assign(it, changes);
});
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
<div className="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
<div className="lg:col-span-5">
<SectionHeading
title={heading.title}
lead={heading.lead}
headingKey="why_us" />
{heading.image && (
<Reveal delay={0.12}>
<img
src={heading.image}
alt={heading.title}
className="mt-10 h-80 w-full object-cover" />
</Reveal>
)}
</div>
<div className="lg:col-span-7">
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
{why_us.map((item, i) =>
<Reveal key={item.id} delay={0.05 * i}>
<EditableItem resource="why_us" item={item}>
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
<h3 className="mt-4 text-lg font-bold text-ivory">
<InlineEdit
value={item.title}
onSave={(v) => saveItem(item.id, { title: v })}
/>
</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">
<InlineEdit
multiline
value={item.desc}
onSave={(v) => saveItem(item.id, { desc: v })}
/>
</p>
</div>
</EditableItem>
</Reveal>
)}
</div>
<div className="mt-8">
<AddItemButton resource="why_us" />
</div>
</div>
</div>
</section>);
}
+3
View File
@@ -0,0 +1,3 @@
// آدرس بک‌اند — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ (/api از طریق Traefik).
// فقط اگر بک‌اند روی مبدأ دیگری سرو می‌شود VITE_API_URL را تنظیم کنید.
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
+43
View File
@@ -0,0 +1,43 @@
// انواع مشترک داده‌ها — همه محتوای متنی سایت از بک‌اند (GET /api/site) بارگذاری می‌شود.
export type Service = {
id: number;
title: string;
desc: string;
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
};
export type ProjectStatus = 'done' | 'progress';
export type Project = {
id: number;
slug: string;
title: string;
location: string;
year: string;
status: ProjectStatus;
image: string;
gallery: string[];
area: string;
floors: string;
units: string;
client: string;
summary: string;
body: string[];
};
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] };
export type ArticleStatus = 'draft' | 'published';
export type Article = {
id: number;
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
/** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */
body: ArticleBodyDoc | string[];
/** پیش‌نویس فقط برای ادمین (از انتهای مدیریتی) می‌آید؛ بازدیدکننده نمی‌بیند */
status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
seo_keyphrase: string;
};
+141
View File
@@ -0,0 +1,141 @@
/* @import url() FONT IMPORTS MUST ALWAYS BE AT THE VERY TOP OF THIS FILE, ABOVE THE TAILWIND IMPORTS — DO NOT DELETE THIS COMMENT */
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap');
/* CRITICAL: THE FOLLOWING TAILWIND IMPORTS MUST NEVER BE DELETED OR REORDERED — DO NOT DELETE THIS COMMENT */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
@font-face {
font-family: 'Pinar';
src: url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar.woff2') format('woff2'),
url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar.woff') format('woff');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'Pinar';
src: url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar-Bold.woff2') format('woff2'),
url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar-Bold.woff') format('woff');
font-weight: 700;
font-display: swap;
}
html {
scroll-behavior: smooth;
}
body {
background-color: #27221c;
color: #f5f3ef;
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
-webkit-font-smoothing: antialiased;
}
::selection {
background-color: #d5874c;
color: #1e1a16;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* ---------- بدنه مقاله — رندر سند TipTap و ویرایشگر WYSIWYG ---------- */
.article-body {
line-height: 2.4;
word-wrap: break-word;
}
.article-body p {
margin-top: 1.75rem;
color: rgba(245, 243, 239, 0.7);
}
.article-body > :first-child {
margin-top: 0;
}
.article-body h2,
.article-body h3 {
color: #f5f3ef;
font-weight: 700;
margin-top: 3rem;
margin-bottom: 0.25rem;
line-height: 1.9;
}
.article-body h2 {
font-size: 1.6rem;
padding-right: 0.85rem;
border-right: 3px solid #d5874c;
}
.article-body h3 {
font-size: 1.25rem;
color: #e8c9a8;
}
.article-body ul,
.article-body ol {
margin-top: 1.5rem;
padding-right: 1.5rem;
}
.article-body ul {
list-style: disc;
}
.article-body ol {
list-style: decimal;
}
.article-body li {
margin-top: 0.6rem;
color: rgba(245, 243, 239, 0.7);
}
.article-body li p {
margin-top: 0;
display: inline;
}
.article-body blockquote {
margin-top: 2rem;
border-right: 3px solid #d5874c;
background: rgba(30, 26, 22, 0.5);
padding: 1rem 1.25rem;
font-style: normal;
color: rgba(245, 243, 239, 0.85);
}
.article-body blockquote p {
margin-top: 0;
color: inherit;
}
.article-body a {
color: #d5874c;
text-decoration: underline;
text-underline-offset: 4px;
}
.article-body img {
width: 100%;
margin-top: 2rem;
border: 1px solid rgba(245, 243, 239, 0.1);
object-fit: cover;
}
.article-body strong {
color: #f5f3ef;
font-weight: 700;
}
.article-body hr {
margin-top: 2.5rem;
border: 0;
height: 1px;
background: rgba(245, 243, 239, 0.12);
}
.article-body code {
background: rgba(30, 26, 22, 0.7);
padding: 0.15rem 0.45rem;
font-size: 0.85em;
}
.article-body pre {
background: rgba(30, 26, 22, 0.7);
padding: 1rem 1.25rem;
margin-top: 1.5rem;
overflow-x: auto;
direction: ltr;
text-align: left;
}
.article-body pre code {
background: transparent;
padding: 0;
}
+9
View File
@@ -0,0 +1,9 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
const rootEl = document.getElementById("root");
if (rootEl) {
ReactDOM.createRoot(rootEl).render(<App />);
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { Story } from '../components/home/Story'
import { Stats } from '../components/home/Stats'
import { Timeline } from '../components/home/Timeline'
import { WhyUs } from '../components/home/WhyUs'
import { Team } from '../components/home/Team'
import { ContactSection } from '../components/ContactSection'
export function About() {
return (
<>
<PageHero pageKey="about" />
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} />
<Stats />
<Timeline />
<WhyUs />
<Team />
<ContactSection />
</>
)
}
+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard';
import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api';
import { applySeo } from '../seo';
import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import type { Article } from '../data/site';
/** بدنه مقاله را به HTML تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
function renderBody(article: Article): { html: string | null; legacy: string[] } {
const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try {
return {
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }),
legacy: [],
};
} catch {
return { html: null, legacy: [] };
}
}
return { html: null, legacy: [] };
}
export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>();
const { articles, brand } = useSite();
const article = articles.find((a) => a.slug === slug);
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
useEffect(() => {
if (!article) return;
applySeo({
title: `${article.title} | ${brand}`,
description: article.excerpt,
path: `/blog/${article.slug}`,
image: article.image,
type: 'article',
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: 'fa',
author: { '@type': 'Organization', name: brand },
publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
},
});
}, [article?.id]);
if (!article) {
return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">این مقاله پیدا نشد</h1>
<p className="mt-4 text-sm font-light text-ivory/60">فهرست کامل مقالات وبلاگ را ببینید.</p>
<ButtonLink to="/blog" variant="copper" className="mt-8">
بازگشت به وبلاگ
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</section>);
}
const { html, legacy } = renderBody(article);
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
return (
<>
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<img src={article.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true" />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
صفحه اصلی
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<Link to="/blog" className="transition-colors hover:text-copper">
وبلاگ
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{article.category}</span>
</nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
<div className="mt-6 flex items-center gap-5 text-xs font-light text-ivory/60">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
</span>
</div>
</div>
</section>
<article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-3xl px-6">
<Reveal>
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80">
{article.excerpt}
</p>
</Reveal>
{html ? (
<Reveal delay={0.05}>
<div
className="article-body mt-4 text-base font-light text-ivory/70"
dangerouslySetInnerHTML={{ __html: html }} />
</Reveal>
) : (
legacy.map((paragraph, i) =>
<Reveal key={i} delay={0.03 * i}>
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
</Reveal>
)
)}
</div>
</article>
<section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط">
<div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">مقالات مرتبط</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) =>
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
<ArticleCard article={item} />
</Reveal>
)}
</div>
</div>
</section>
<Newsletter />
</>);
}
+132
View File
@@ -0,0 +1,132 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import { PageHero } from '../components/PageHero'
import { Reveal } from '../components/Reveal'
import { ArticleCard } from '../components/ArticleCard'
import { Newsletter } from '../components/home/Newsletter'
import { useSite } from '../api'
import { EditableItem } from '../admin/Editable'
import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) {
return (
<span
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
>
پیش‌نویس
</span>
)
}
export function Blog() {
const { articles } = useSite()
// حالت خالی — وبلاگ بدون مقاله نباید کرش کند
if (articles.length === 0) {
return (
<>
<PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
<h2 className="text-3xl font-bold text-ivory">هنوز مقاله‌ای منتشر نشده است</h2>
<p className="mt-4 text-sm font-light text-ivory/60">
به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.
</p>
<Link
to="/admin/write"
data-admin-link
className="mt-8 inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
>
<PlusIcon className="h-4 w-4" />
نوشتن مقاله جدید
</Link>
</div>
</section>
<Newsletter />
</>
)
}
const [featured, ...rest] = articles
return (
<>
<PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mb-10 flex justify-start">
<Link
to="/admin/write"
data-admin-link
className="flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
>
<PlusIcon className="h-4 w-4" />
نوشتن مقاله جدید
</Link>
</div>
<Reveal>
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
<Link
to={`/blog/${featured.slug}`}
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
aria-label={`خواندن مقاله ${featured.title}`}
>
<div className="overflow-hidden">
<img
src={featured.image}
alt={`تصویر مقاله ${featured.title}`}
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</div>
<div className="flex flex-col justify-center p-8 lg:p-12">
<p className="text-xs font-light text-copper">
مقاله ویژه — {featured.category}
{featured.status === 'draft' && (
<DraftBadge className="mr-3 inline-block align-middle" />
)}
</p>
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
{featured.title}
</h2>
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{featured.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{featured.read}
</span>
</span>
<span className="flex items-center gap-2 text-copper">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-200 ease-lux group-hover:-translate-x-1" />
</span>
</div>
</div>
</Link>
</EditableItem>
</Reveal>
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{rest.map((article, i) => (
<Reveal key={article.id} delay={0.05 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<div className="relative h-full">
{article.status === 'draft' && (
<DraftBadge className="absolute left-3 top-3 z-10" />
)}
<ArticleCard article={article} />
</div>
</EditableItem>
</Reveal>
))}
</div>
</div>
</section>
<Newsletter />
</>
)
}
+39
View File
@@ -0,0 +1,39 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal'
import { useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function ContactPage() {
const { offices } = useSite()
return (
<>
<PageHero pageKey="contact" />
<section className="bg-ink py-24" aria-label="دفاتر ما">
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{offices.map((office, i) => (
<Reveal key={office.id} delay={0.06 * i} className="h-full">
<EditableItem resource="offices" item={office} className="h-full">
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
<div className="mt-auto pt-10">
<p className="border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
{office.hours}
</p>
</div>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="offices" />
</div>
</section>
<ContactSection />
</>
)
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { Faq } from '../components/home/Faq'
import { ContactSection } from '../components/ContactSection'
export function FaqPage() {
return (
<>
<PageHero pageKey="faq" />
<Faq hideHeading={true} />
<ContactSection />
</>
)
}
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { Hero } from '../components/home/Hero';
import { Story } from '../components/home/Story';
import { Stats } from '../components/home/Stats';
import { Services } from '../components/home/Services';
import { BeforeAfter } from '../components/home/BeforeAfter';
import { FeaturedProjects } from '../components/home/FeaturedProjects';
import { Gallery } from '../components/home/Gallery';
import { Timeline } from '../components/home/Timeline';
import { WhyUs } from '../components/home/WhyUs';
import { Testimonials } from '../components/home/Testimonials';
import { Team } from '../components/home/Team';
import { Articles } from '../components/home/Articles';
import { Faq } from '../components/home/Faq';
import { Newsletter } from '../components/home/Newsletter';
import { ContactSection } from '../components/ContactSection';
export function Home() {
return (
<>
<Hero />
<Story />
<Stats />
<Services />
<BeforeAfter />
<FeaturedProjects />
<Gallery />
<Timeline />
<WhyUs />
<Testimonials />
<Team />
<Articles />
<Faq />
<Newsletter />
<ContactSection />
</>);
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { Reveal } from '../components/Reveal'
import { SectionHeading } from '../components/SectionHeading'
import { Stats } from '../components/home/Stats'
import { ContactSection } from '../components/ContactSection'
import { useSectionHeading, useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function Investment() {
const { investment_models } = useSite()
const heading = useSectionHeading('investment_models')
return (
<>
<PageHero pageKey="investment" />
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} />
<div className="mt-14 grid gap-6 lg:grid-cols-3">
{investment_models.map((model, i) => (
<Reveal key={model.id} delay={0.06 * i} className="h-full">
<EditableItem resource="investment_models" item={model} className="h-full">
<article className="group flex h-full flex-col border border-ivory/10 bg-char p-10 transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
<h3 className="mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{model.title}
</h3>
<p className="mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p>
<div className="mt-auto pt-10">
<ul className="space-y-4 border-t border-ivory/10 pt-8">
{model.points.map((point) => (
<li key={point} className="flex items-start gap-3 text-sm font-light leading-8 text-ivory/65">
<span className="mt-3.5 h-1 w-1 shrink-0 rounded-full bg-copper transition-transform duration-300 ease-out group-hover:scale-[1.5]" aria-hidden="true" />
{point}
</li>
))}
</ul>
</div>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="investment_models" />
</div>
</div>
</section>
<Stats />
<ContactSection />
</>
)
}
+173
View File
@@ -0,0 +1,173 @@
import React, { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
import { Reveal } from '../components/Reveal'
import { ButtonLink } from '../components/Button'
import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox'
import { useEffect } from 'react';
import { useSite, useStatusLabel } from '../api';
import { applySeo } from '../seo';
export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>()
const { projects, brand } = useSite()
const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null)
// عنوان تب، توضیح متا و OG از خود پروژه
useEffect(() => {
if (!project) return;
applySeo({
title: `${project.title} | ${brand}`,
description: project.summary,
path: `/projects/${project.slug}`,
image: project.image,
});
}, [project?.id]);
if (!project) {
return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">این پروژه پیدا نشد</h1>
<p className="mt-4 text-sm font-light text-ivory/60">
ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.
</p>
<ButtonLink to="/projects" variant="copper" className="mt-8">
بازگشت به پروژه‌ها
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</section>
)
}
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
const facts = [
{ label: 'موقعیت', value: project.location },
{ label: 'سال', value: project.year },
{ label: 'زیربنا', value: project.area },
{ label: 'تعداد طبقات', value: project.floors },
{ label: 'واحدها', value: project.units },
{ label: 'کارفرما', value: project.client },
]
return (
<>
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
صفحه اصلی
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<Link to="/projects" className="transition-colors hover:text-copper">
پروژه‌ها
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{project.title}</span>
</nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
</div>
</section>
<section className="bg-char py-20 lg:py-28" aria-label="جزئیات پروژه">
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
<Reveal className="order-1">
<span className="flex items-center gap-3 text-sm font-light text-copper">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2>
{project.body.map((para) => (
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 text-right">
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
<div className="mt-10">
<ButtonLink to="/contact" variant="outline" className="font-light">
پروژه‌ای مشابه دارید؟
</ButtonLink>
</div>
</Reveal>
<Reveal delay={0.1} className="order-2">
<button
type="button"
onClick={() => setLightbox(0)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={project.gallery[0] || project.image}
alt=""
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
<div className="mt-4 grid grid-cols-3 gap-4">
{project.gallery.slice(1).map((img, i) => (
<button
key={img}
type="button"
onClick={() => setLightbox(i + 1)}
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={img}
alt=""
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
))}
</div>
</Reveal>
</div>
</section>
<section className="bg-ink py-20 lg:py-28" aria-label="پروژه‌های مرتبط">
<div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">پروژه‌های دیگر</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => (
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
<ProjectCard project={item} />
</Reveal>
))}
</div>
</div>
</section>
<Lightbox
images={project.gallery}
index={lightbox}
onClose={() => setLightbox(null)}
onChange={setLightbox}
/>
<ContactSection />
</>
)
}
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { PageHero } from '../components/PageHero';
import { Reveal } from '../components/Reveal';
import { ProjectCard } from '../components/ProjectCard';
import { Gallery } from '../components/home/Gallery';
import { ContactSection } from '../components/ContactSection';
import type { ProjectStatus } from '../data/site';
import { useSite, useStatusLabel } from '../api';
import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit';
import { AddItemButton, EditableItem } from '../admin/Editable';
export function Projects() {
const { projects, status_labels } = useSite();
const save = useFieldSaver();
const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key);
if (!label) return;
void save('status-labels', label.id, { label: value }, (d) => {
const s = d.status_labels.find((x) => x.id === label.id);
if (s) s.label = value;
});
};
const doneLabel = useStatusLabel('done');
const progressLabel = useStatusLabel('progress');
const FILTERS: {key: ProjectStatus;label: string;}[] = [
{ key: 'done', label: doneLabel },
{ key: 'progress', label: progressLabel }];
const [status, setStatus] = useState<ProjectStatus>('done');
const list = projects.filter((p) => p.status === status);
return (
<>
<PageHero pageKey="projects" />
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژه‌ها">
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="flex flex-wrap gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter) =>
<button
key={filter.key}
type="button"
role="tab"
aria-selected={status === filter.key}
onClick={() => setStatus(filter.key)}
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-lux ${
status === filter.key ?
'border-copper bg-copper font-bold text-char' :
'border-ivory/15 font-light text-ivory/65'}`
}>
{status !== filter.key &&
<span
aria-hidden="true"
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0" />
}
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
<InlineEdit
value={filter.label}
onSave={(v) => saveStatusLabel(filter.key, v)}
/>
</span>
</button>
)}
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{list.map((project, i) =>
<Reveal key={project.id} delay={0.05 * i} className="h-full">
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} imageClass="h-80" />
</EditableItem>
</Reveal>
)}
<AddItemButton resource="projects" />
</div>
</div>
</section>
<Gallery />
<ContactSection />
</>);
}
+45
View File
@@ -0,0 +1,45 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { Services } from '../components/home/Services'
import { Faq } from '../components/home/Faq'
import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal'
import { SectionHeading } from '../components/SectionHeading'
import { useSectionHeading, useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function ServicesPage() {
const { process_steps } = useSite()
const heading = useSectionHeading('process')
return (
<>
<PageHero pageKey="services" />
<Services />
<section className="bg-char py-24 lg:py-32" aria-labelledby="process-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} align="center" />
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
{process_steps.map((item, i) => (
<Reveal key={item.id} delay={0.06 * i} className="h-full">
<EditableItem resource="process_steps" item={item} className="h-full">
<div className="flex h-full flex-col justify-center bg-char px-6 py-14 text-center">
<p className="text-4xl font-bold text-copper">{item.step}</p>
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3>
<p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
</div>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="process_steps" />
</div>
</div>
</section>
<Faq />
<ContactSection />
</>
)
}
+94
View File
@@ -0,0 +1,94 @@
// تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
// محتوا از دیتابیس می‌آید؛ برای خزنده‌های بدون جاوااسکریپت، بک‌اند همان تگ‌ها را
// سمت سرور داخل index.html تزریق می‌کند (app/seo_pages.py).
export function setMetaDescription(content: string): void {
// بدون توضیح از دیتابیس، پیش‌فرض استاتیک index.html دست‌نخورده می‌ماند
if (!content.trim()) return
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
if (!el) {
el = document.createElement('meta')
el.setAttribute('name', 'description')
document.head.appendChild(el)
}
el.setAttribute('content', content.trim())
}
/** کلید سئوی مسیر جاری — فقط صفحه‌های فهرست دقیق؛ صفحه‌های جزئیات خودشان عنوان می‌گذارند */
export function seoKeyForPath(pathname: string): string | null {
if (pathname === '/') return 'home'
const seg = pathname.split('/')[1] ?? ''
const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact']
// مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند
return known.includes(seg) && pathname === `/${seg}` ? seg : null
}
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, key)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
function upsertCanonical(path: string): void {
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
if (!el) {
el = document.createElement('link')
el.setAttribute('rel', 'canonical')
document.head.appendChild(el)
}
el.setAttribute('href', new URL(path, window.location.origin).href)
}
function setJsonLd(data: Record<string, unknown> | null): void {
const id = 'seo-jsonld'
let el = document.getElementById(id)
if (!data) {
el?.remove()
return
}
if (!el) {
el = document.createElement('script')
el.setAttribute('type', 'application/ld+json')
el.id = id
document.head.appendChild(el)
}
el.textContent = JSON.stringify(data)
}
export type SeoInput = {
title: string
description?: string
/** مسیر canonical — پیش‌فرض مسیر جاری مرورگر */
path?: string
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل می‌شود */
image?: string
type?: 'website' | 'article'
jsonLd?: Record<string, unknown> | null
}
/** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */
export function applySeo(input: SeoInput): void {
const title = input.title.trim()
const description = (input.description ?? '').trim()
const path = input.path ?? window.location.pathname
const image = (input.image ?? '').trim()
document.title = title
setMetaDescription(description)
upsertCanonical(path)
upsertMeta('property', 'og:title', title)
upsertMeta('property', 'og:description', description)
upsertMeta('property', 'og:type', input.type ?? 'website')
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
upsertMeta('name', 'twitter:card', 'summary_large_image')
if (image) {
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
} else {
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
}
setJsonLd(input.jsonLd ?? null)
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+31
View File
@@ -0,0 +1,31 @@
export default {
content: ['./**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
ink: '#27221c',
char: '#1e1a16',
copper: '#d5874c',
ivory: '#F5F3EF',
},
fontFamily: {
pinar: ['Pinar', 'Vazirmatn', 'Tahoma', 'sans-serif'],
},
transitionTimingFunction: {
lux: 'cubic-bezier(0.23, 1, 0.32, 1)',
},
boxShadow: {
lift: '0 24px 50px -18px rgba(0, 0, 0, 0.75)',
},
fontSize: {
// سایز این بخش یک درجه کوچک‌تر شد (هم در موبایل و هم در دسکتاپ)
hero: ['clamp(1.75rem, 3.8vw, 3.5rem)', { lineHeight: '1.35' }],
section: ['clamp(1.6rem, 2.4vw, 2.4rem)', { lineHeight: '1.4' }],
},
maxWidth: {
site: '1440px',
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/media': {
target: 'http://localhost:9000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/media/, '/aramesh-media'),
},
},
},
})
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# پشتیبان‌گیری روزانه — دیتابیس PostgreSQL (همه محتوای سایت) و تصاویر MinIO.
#
# اجرا: bash scripts/backup.sh
# زمان‌بندی: cron (لینوکس) یا Task Scheduler (ویندوز)، مثلا هر شب ساعت ۳:
# 0 3 * * * cd /path/to/aramesh-backend && bash scripts/backup.sh >> backups/backup.log 2>&1
#
# نگهداری: فایل‌های قدیمی‌تر از RETENTION_DAYS (پیش‌فرض ۱۴) حذف می‌شوند.
set -euo pipefail
cd "$(dirname "$0")/.."
RETENTION_DAYS="${RETENTION_DAYS:-14}"
STAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p backups
# متغیرهای .env (برای MINIO_ROOT_*) در دسترس قرار می‌گیرند
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
echo "[backup] $STAMP"
# ۱) دیتابیس — همه متن‌ها، مقاله‌ها و تنظیمات سایت
DB_FILE="backups/db-${STAMP}.sql.gz"
docker compose exec -T db pg_dump -U aramesh arameshplaza | gzip > "$DB_FILE"
echo "[backup] db: $DB_FILE ($(du -h "$DB_FILE" | cut -f1))"
# ۲) تصاویر MinIO — باکت aramesh-media
docker compose run --rm -v "$(pwd)/backups:/backups" --entrypoint sh mc -c "
mc alias set local http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null &&
mc mirror --overwrite local/aramesh-media /backups/images-${STAMP}
"
echo "[backup] images: backups/images-${STAMP}"
# ۳) پاک‌سازی نسخه‌های قدیمی‌تر
find backups -name 'db-*.sql.gz' -mtime "+${RETENTION_DAYS}" -delete
find backups -maxdepth 1 -type d -name 'images-*' -mtime "+${RETENTION_DAYS}" -exec rm -rf {} +
echo "[backup] پاک‌سازی نسخه‌های قدیمی‌تر از ${RETENTION_DAYS} روز انجام شد"
echo "[backup] ✔ تمام شد"