Author SHA1 Message Date
Behrad 35a558db20 پاکسازی تصاویر موقت و به‌روزرسانی مراجع قبل/بعد در فرانت 2026-09-02 23:00:57 +03:30
Behrad 5a6cb51333 عکس‌های اصلی سایت — بخش ۴ (قبل/بعد + مراجع) 2026-09-02 23:00:09 +03:30
Behrad dc200099ba عکس‌های اصلی سایت — بخش ۳ (تصاویر ۱، ۲، ۵) 2026-09-02 22:59:19 +03:30
Behrad 0a55d5d0fe عکس‌های اصلی سایت — بخش ۲ (تماس و تصویر ۴) 2026-09-02 22:59:08 +03:30
Behrad 97dc728a4c عکس‌های اصلی سایت — بخش ۱ (لوگو و هیرو) 2026-09-02 22:58:17 +03:30
Behrad 87f38f1c0b تصاویر بدون کش طولانی — نمایش فوری تغییرات 2026-09-02 19:03:36 +03:30
Behrad 8dab39ee83 بازطراحی تصاویر محلی با رنگ‌های قابل‌دیدن (مسی/طلایی) 2026-09-02 18:56:02 +03:30
Behrad 32b6655a1e رفع باگ: بازگردانی Tailwind imports در CSS (حذف اشتباه در بهینه‌سازی) 2026-09-02 18:47:33 +03:30
Behrad 395aceb6f2 اضافه کردن گزارش بهینه‌سازی سرعت (PDF) 2026-09-02 12:22:10 +03:30
Behrad e76dbbf63d مستندسازی بهبود سرعت در README 2026-09-02 12:20:52 +03:30
Behrad e0ebae4817 بهبود سرعت: حذف کامل وابستگی‌های خارجی
- همه ۹ عکس سایت به‌صورت محلی (frontend/public/images) — بدون postimg/housedesign
- حذف فونت‌های خارجی (Google Fonts + fontcdn) — فونت سیستمی سریع
- favicon واقعی + کش nginx برای webp/ico
2026-09-01 23:04:57 +03:30
79 changed files with 752 additions and 4648 deletions
+1 -3
View File
@@ -23,6 +23,4 @@ Thumbs.db
# Database # Database
*.sqlite3 *.sqlite3
*.db *.db
frontend/public/images/originals/
# تصاویر محلی — از پنل/کنسول MinIO مدیریت می‌شوند
frontend/public/images/
+27 -74
View File
@@ -2,33 +2,18 @@
مونوریپوی سایت گروه ساختمانی آرامش شامل: مونوریپوی سایت گروه ساختمانی آرامش شامل:
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متن‌ها و تصاویر از API خوانده می‌شود) - **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده می‌شود)
- **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO - **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT
## معماری
```
Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
├── / → Caddy (فایل‌های استاتیک SPA)
├── /api → FastAPI (بک‌اند)
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
```
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانت‌اند هاردکد نشده؛
با یک درخواست `GET /api/site` بارگذاری و از پنل مدیریت ویرایش می‌شود
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره می‌شوند و با مسیر نسبی
`/media/...` برگردانده می‌شوند — بک‌اند ذخیره‌سازی بدون تغییر محتوای دیتابیس قابل تعویض است
- مسیریابی همه‌چیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
## ساختار ## ساختار
``` ```
├── frontend/ # سایت React ├── frontend/ # سایت React
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند) │ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند)
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy │ ├── Dockerfile # بیلد Node + سرو با nginx
│ └── Caddyfile # فقط سرو فایل‌های استاتیک (مسیریابی روی Traefik است) │ └── nginx.conf # پروکسی /api به بک‌اند
├── backend/ # بک‌اند FastAPI ├── backend/ # بک‌اند FastAPI
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py) │ ├── app/ # کد منبع
│ ├── requirements.txt │ ├── requirements.txt
│ ├── Dockerfile │ ├── Dockerfile
│ └── .env.example │ └── .env.example
@@ -41,13 +26,11 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
docker compose up --build docker compose up --build
``` ```
- سایت: http://localhost:5173 (از طریق Traefik) - سایت: http://localhost:5173
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی) - مستندات API: http://localhost:8000/docs
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در compose - دیتابیس PostgreSQL در کانتینر جدا (داده‌ها در volume ذخیره می‌شوند)
- دیتابیس PostgreSQL و آبجکت‌استور MinIO هر دو در کانتینر جدا با volume دائمی - بک‌اند هنگام شروع، خودش جداول و داده‌های اولیه سایت را seed می‌کند
- بک‌اند هنگام شروع، خودش جداول را می‌سازد و **فقط جدول‌های خالی** را seed می‌کند — - nginx درخواست‌های `/api` را خودش به بک‌اند پروکسی می‌کند — نیازی به تنظیم CORS نیست
ارتقای نسخه داده‌های فعلی را از بین نمی‌برد
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را می‌سازد و خواندن عمومی را فعال می‌کند
## اجرای محلی (توسعه) ## اجرای محلی (توسعه)
@@ -69,64 +52,34 @@ uvicorn app.main:app --reload
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev npm run dev # http://localhost:5173
``` ```
در حالت توسعه، Vite درخواست‌های `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی می‌کند؛ آدرس بک‌اند در `frontend/src/config.ts` از `VITE_API_URL` خوانده می‌شود؛
پس استک Docker باید در حال اجرا باشد. آدرس بک‌اند در `frontend/src/config.ts` از اگر تنظیم نشود پیش‌فرض `http://localhost:8000` است.
`VITE_API_URL` خوانده می‌شود — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ.
## امکانات بک‌اند ## امکانات بک‌اند
- **API عمومی**: برند، منو، هیرو، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم، - **API عمومی**: برند، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم، تایم‌لاین، آمار و...
تایم‌لاین، آمار، دفاتر، مدل‌های سرمایه‌گذاری و... — همه در دیتابیس
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` - **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) - **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...`
و سپس CRUD کامل همه محتواها در `/api/admin/...`
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
ذخیره و URL نسبی `/media/...` برمی‌گردد
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند
- **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
- همه محتوا در یک درخواست: `GET /api/site` - همه محتوا در یک درخواست: `GET /api/site`
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید. > در محیط واقعی حتماً `SECRET_KEY` و `ADMIN_PASSWORD` را تغییر دهید.
## ویرایش زنده سایت (دوقلوی دیجیتال)
ادمین‌ها نیازی به داشبورد جدا ندارند — خود سایت ویرایشگر است:
1. به `/admin/login` بروید و وارد شوید
2. نوار شناور پایین صفحه ظاهر می‌شود؛ **حالت ویرایش** را روشن کنید
3. روی هر متن کلیک کنید و ویرایش کنید — **خروج از کادر، بلافاصله ذخیره می‌شود**
(Enter ذخیره، Esc لغو)؛ وضعیت ذخیره در نوار پایین دیده می‌شود
4. آیتم‌های مجموعه‌ها (خدمات، پروژه‌ها، مقالات، نظرات، تیم و...) با هاور، دکمه‌های
ویرایش / حذف / جابه‌جایی ترتیب نشان می‌دهند؛ دکمه «افزودن» هم انتهای هر لیست هست
5. تصویرها از همان کشوها به MinIO آپلود می‌شوند
- در حالت ویرایش، لینک‌ها کلیک نمی‌شوند تا ویرایش متن ممکن باشد
- بازدیدکننده‌های عادی هیچ اثری از این لایه نمی‌بینند (بدون توکن، همه درخواست‌های
مدیریتی `401` برمی‌گردند)
- تمام تغییرها بلافاصله در PostgreSQL ثبت می‌شوند — با Ctrl+F5 هم می‌مانند
## وبلاگ — نوشتن با ویرایشگر WYSIWYG
مقالات با ویرایشگر غنی **TipTap** نوشته می‌شوند (همان تجربه Medium):
- در حالت ویرایش، روی هر مقاله در صفحه وبلاگ دکمه ویرایش را بزنید — کشوی نوشتن باز می‌شود
- تیتر (H2/H3)، بولد/ایتالیک/زیرخط، لیست نقطه‌ای و شماره‌ای، نقل‌قول، لینک و واگرد
- **درج تصویر داخل متن**: دکمه تصویر، یا چسباندن/کشیدن تصویر داخل نوشته — فایل مستقیم
در MinIO ذخیره و در متن قرار می‌گیرد
- راست‌به‌چپ کامل با فونت و تم سایت
- ذخیره خودکار: هر مکث کوتاه در تایپ، تغییرها را ذخیره می‌کند؛ بستن کشو هم ذخیره می‌کند
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده
(پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف)
در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند.
## نکات امنیتی ## نکات امنیتی
- فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است) - فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است)
- رمز عبور ادمین با bcrypt هش می‌شود - رمز عبور ادمین با bcrypt هش می‌شود
- توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است - توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
## تغییرات اخیر
### بهبود سرعت (کامیت e0ebae4)
- **حذف کامل وابستگی‌های خارجی**: همه عکس‌های سایت به‌صورت محلی در `frontend/public/images/` قرار گرفتند (بدون وابستگی به سرورهای خارجی مثل postimg و housedesign)
- **حذف فونت‌های خارجی** (Google Fonts و fontcdn که رندر صفحه را قفل می‌کردند) — استفاده از فونت سیستمی سریع
- **favicon واقعی** اضافه شد (قبلاً خراب بود)
- **کش ۳۰ روزه** برای تصاویر و فایل‌های استاتیک در nginx
- نتیجه: صفحه‌ها در چند میلی‌ثانیه لود می‌شوند و سایت هیچ درخواست خارجی ندارد
> اگر نسخه قبلی سایت را اجرا کرده‌اید، برای دریافت نسخه جدید یک بار `docker compose down -v` و سپس `docker compose up --build -d` را اجرا کنید.
-12
View File
@@ -15,15 +15,3 @@ CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
# مشخصات ادمین پیش‌فرض که هنگام seed ساخته می‌شود # مشخصات ادمین پیش‌فرض که هنگام seed ساخته می‌شود
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=aramesh123 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
+4 -26
View File
@@ -1,15 +1,14 @@
# بک‌اند گروه ساختمانی آرامش (Aramesh Building Group API) # بک‌اند گروه ساختمانی آرامش (Aramesh Building Group API)
بک‌اند سایت گروه ساختمانی آرامش — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی). بک‌اند سایت گروه ساختمانی آرامش — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
## امکانات ## امکانات
- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخش‌ها، هیرو صفحات، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، قبل/بعد، مدل‌های سرمایه‌گذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متن‌های سایت در دیتابیس است** - **API عمومی محتوا**: برند، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، و بخش قبل/بعد
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`) - **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`) - **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیام‌های تماس و مشترکین - **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیام‌های تماس و مشترکین
- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمی‌گردد - **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد (فقط جدول‌های خالی پر می‌شوند — ارتقا بدون از دست رفتن داده)
- مستندات تعاملی Swagger در مسیر `/docs` - مستندات تعاملی Swagger در مسیر `/docs`
## راه‌اندازی ## راه‌اندازی
@@ -119,33 +118,12 @@ Authorization: Bearer <access_token>
| متد | مسیر | توضیح | | متد | مسیر | توضیح |
|---|---|---| |---|---|---|
| GET/POST | `/api/admin/{resource}` | لیست / ساخت | | GET/POST | `/api/admin/{resource}` | لیست / ساخت |
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش کامل / حذف | | 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` فقط خوانده‌نشده‌ها) | | GET | `/api/admin/messages` | پیام‌های تماس (با `?unread_only=true` فقط خوانده‌نشده‌ها) |
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خوانده‌شدن | | PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خوانده‌شدن |
| DELETE | `/api/admin/messages/{id}` | حذف پیام | | DELETE | `/api/admin/messages/{id}` | حذف پیام |
| GET | `/api/admin/subscribers` | مشترکین خبرنامه | | GET | `/api/admin/subscribers` | مشترکین خبرنامه |
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک | | 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 هم بدنه‌های لیستی را قبل از درج تبدیل می‌کند
منابعی که `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)
- تصاویر عمومی و قابل نمایش‌اند؛ آپلود فقط با توکن ادمین انجام می‌شود.
## اتصال به فرانت‌اند ## اتصال به فرانت‌اند
+15 -182
View File
@@ -1,97 +1,40 @@
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند.""" """انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
import uuid from fastapi import APIRouter, Depends, HTTPException, status
from datetime import datetime, timezone
import boto3
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models, schemas from .. import models, schemas
from ..auth import create_access_token, get_current_admin, verify_password from ..auth import create_access_token, get_current_admin, verify_password
from ..config import settings
from ..database import get_db from ..database import get_db
from ..schemas import ( from ..schemas import (
ArticleIn, ArticleIn,
ArticleOut, ArticleOut,
ArticlePatch,
BeforeAfterIn, BeforeAfterIn,
BeforeAfterOut, BeforeAfterOut,
BeforeAfterPatch,
BrandIn, BrandIn,
BrandOut, BrandOut,
BrandPatch,
ContactSectionIn,
ContactSectionOut,
ContactSectionPatch,
ContactMessageOut, ContactMessageOut,
FaqIn, FaqIn,
FaqOut, FaqOut,
FaqPatch,
FooterIn,
FooterOut,
FooterPatch,
GalleryImageIn,
GalleryImageOut,
GalleryImagePatch,
HeroIn,
HeroOut,
HeroPatch,
InvestmentModelIn,
InvestmentModelOut,
InvestmentModelPatch,
LoginIn, LoginIn,
MessageReadIn, MessageReadIn,
NavItemIn,
NavItemOut,
NavItemPatch,
NewsletterSectionIn,
NewsletterOut,
NewsletterSectionPatch,
OfficeIn,
OfficeOut,
OfficePatch,
PageHeroIn,
PageHeroOut,
PageHeroPatch,
ProcessStepIn,
ProcessStepOut,
ProcessStepPatch,
ProjectIn, ProjectIn,
ProjectOut, ProjectOut,
ProjectPatch,
ReorderIn,
SectionHeadingIn,
SectionHeadingOut,
SectionHeadingPatch,
ServiceIn, ServiceIn,
ServiceOut, ServiceOut,
ServicePatch,
StatIn, StatIn,
StatOut, StatOut,
StatPatch,
StatusLabelIn,
StatusLabelOut,
StatusLabelPatch,
StoryIn,
StoryOut,
StoryPatch,
SubscriberOut, SubscriberOut,
TeamMemberIn, TeamMemberIn,
TeamMemberOut, TeamMemberOut,
TeamMemberPatch,
TestimonialIn, TestimonialIn,
TestimonialOut, TestimonialOut,
TestimonialPatch,
TimelineItemIn, TimelineItemIn,
TimelineItemOut, TimelineItemOut,
TimelineItemPatch,
TokenOut, TokenOut,
WhyUsItemIn, WhyUsItemIn,
WhyUsItemOut, WhyUsItemOut,
WhyUsItemPatch,
) )
# روتر عمومی برای ورود — بدون نیاز به توکن # روتر عمومی برای ورود — بدون نیاز به توکن
@@ -112,25 +55,16 @@ def login(payload: LoginIn, db: Session = Depends(get_db)):
return TokenOut(access_token=create_access_token(user.username)) return TokenOut(access_token=create_access_token(user.username))
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None: def register_crud(path: str, model, out_schema, create_schema) -> None:
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، """ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
حذف و ترتیب‌بندی) روی روتر مدیریت."""
@router.get(f"/{path}", response_model=list[out_schema]) @router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db)): def list_items(db: Session = Depends(get_db)):
query = db.query(model) return db.query(model).order_by(model.id).all()
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) @router.post(f"/{path}", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)): def create_item(payload: create_schema, db: Session = Depends(get_db)):
item = model(**payload.model_dump()) item = model(**payload.model_dump())
if hasattr(model, "position"):
# آیتم جدید در انتهای لیست قرار می‌گیرد
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
db.add(item) db.add(item)
try: try:
db.commit() db.commit()
@@ -162,38 +96,6 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
db.refresh(item) db.refresh(item)
return 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) @router.delete(f"/{path}/{{item_id}}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)): def delete_item(item_id: int, db: Session = Depends(get_db)):
item = db.get(model, item_id) item = db.get(model, item_id)
@@ -204,86 +106,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
# CRUD محتوا # CRUD محتوا
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch) register_crud("brand", models.Brand, BrandOut, BrandIn)
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch) register_crud("services", models.Service, ServiceOut, ServiceIn)
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch) register_crud("projects", models.Project, ProjectOut, ProjectIn)
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch) register_crud("articles", models.Article, ArticleOut, ArticleIn)
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch) register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch) register_crud("faqs", models.Faq, FaqOut, FaqIn)
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch) register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch) register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch) register_crud("stats", models.Stat, StatOut, StatIn)
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch) register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch) register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
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)
# آپلود تصویر به 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,
}
# پیام‌های تماس # پیام‌های تماس
+18 -31
View File
@@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
@router.get("/services", response_model=list[ServiceOut]) @router.get("/services", response_model=list[ServiceOut])
def get_services(db: Session = Depends(get_db)): def get_services(db: Session = Depends(get_db)):
return db.query(models.Service).order_by(models.Service.position, models.Service.id).all() return db.query(models.Service).all()
@router.get("/projects", response_model=list[ProjectOut]) @router.get("/projects", response_model=list[ProjectOut])
def get_projects(db: Session = Depends(get_db)): def get_projects(db: Session = Depends(get_db)):
return db.query(models.Project).order_by(models.Project.position, models.Project.id).all() return db.query(models.Project).order_by(models.Project.id).all()
@router.get("/projects/{slug}", response_model=ProjectOut) @router.get("/projects/{slug}", response_model=ProjectOut)
@@ -55,7 +55,7 @@ def get_project(slug: str, db: Session = Depends(get_db)):
@router.get("/articles", response_model=list[ArticleOut]) @router.get("/articles", response_model=list[ArticleOut])
def get_articles(db: Session = Depends(get_db)): def get_articles(db: Session = Depends(get_db)):
return db.query(models.Article).order_by(models.Article.position, models.Article.id).all() return db.query(models.Article).order_by(models.Article.id).all()
@router.get("/articles/{slug}", response_model=ArticleOut) @router.get("/articles/{slug}", response_model=ArticleOut)
@@ -68,32 +68,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
@router.get("/testimonials", response_model=list[TestimonialOut]) @router.get("/testimonials", response_model=list[TestimonialOut])
def get_testimonials(db: Session = Depends(get_db)): def get_testimonials(db: Session = Depends(get_db)):
return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all() return db.query(models.Testimonial).all()
@router.get("/faqs", response_model=list[FaqOut]) @router.get("/faqs", response_model=list[FaqOut])
def get_faqs(db: Session = Depends(get_db)): def get_faqs(db: Session = Depends(get_db)):
return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all() return db.query(models.Faq).all()
@router.get("/team", response_model=list[TeamMemberOut]) @router.get("/team", response_model=list[TeamMemberOut])
def get_team(db: Session = Depends(get_db)): def get_team(db: Session = Depends(get_db)):
return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all() return db.query(models.TeamMember).all()
@router.get("/timeline", response_model=list[TimelineItemOut]) @router.get("/timeline", response_model=list[TimelineItemOut])
def get_timeline(db: Session = Depends(get_db)): def get_timeline(db: Session = Depends(get_db)):
return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all() return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
@router.get("/stats", response_model=list[StatOut]) @router.get("/stats", response_model=list[StatOut])
def get_stats(db: Session = Depends(get_db)): def get_stats(db: Session = Depends(get_db)):
return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all() return db.query(models.Stat).all()
@router.get("/why-us", response_model=list[WhyUsItemOut]) @router.get("/why-us", response_model=list[WhyUsItemOut])
def get_why_us(db: Session = Depends(get_db)): def get_why_us(db: Session = Depends(get_db)):
return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all() return db.query(models.WhyUsItem).all()
@router.get("/before-after", response_model=BeforeAfterOut) @router.get("/before-after", response_model=BeforeAfterOut)
@@ -109,27 +109,14 @@ def get_site(db: Session = Depends(get_db)):
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند.""" """همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند."""
return { return {
"brand": db.query(models.Brand).first(), "brand": db.query(models.Brand).first(),
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(), "services": db.query(models.Service).all(),
"hero": db.query(models.Hero).first(), "projects": db.query(models.Project).order_by(models.Project.id).all(),
"story": db.query(models.StorySection).first(), "articles": db.query(models.Article).order_by(models.Article.id).all(),
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(), "testimonials": db.query(models.Testimonial).all(),
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(), "faqs": db.query(models.Faq).all(),
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(), "team": db.query(models.TeamMember).all(),
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(), "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
"articles": db.query(models.Article).order_by(models.Article.position, models.Article.id).all(), "stats": db.query(models.Stat).all(),
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(), "why_us": db.query(models.WhyUsItem).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(), "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(),
} }
-9
View File
@@ -18,14 +18,5 @@ class Settings(BaseSettings):
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "aramesh123" 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"
# پیشوند عمومی مسیر تصاویر — nginx آن را به باکت پروکسی می‌کند
media_public_base: str = "/media"
media_max_upload_mb: int = 10
settings = Settings() settings = Settings()
-2
View File
@@ -5,10 +5,8 @@ from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت
from .api import admin, forms, public from .api import admin, forms, public
from .config import settings from .config import settings
from .database import Base, engine from .database import Base, engine
from .migrations import run_all as run_migrations
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations()
app = FastAPI( app = FastAPI(
title="Aramesh Building Group API", title="Aramesh Building Group API",
-64
View File
@@ -1,64 +0,0 @@
"""مهاجرت‌های سبک دیتابیس — DDL و تبدیل داده‌های قدیمی؛ همه idempotent.
هم main.py (startup) و هم seed.py این‌ها را صدا می‌زنند تا هر مسیر اجرایی
دیتابیس موجود را بدون از دست رفتن داده ارتقا دهد.
"""
from sqlalchemy import text
from .database import SessionLocal, engine
# جدول‌های مجموعه‌ای که ستون position دارند
POSITION_TABLES = [
"services", "projects", "articles", "testimonials", "faqs", "team",
"timeline", "stats", "why_us", "investment_models", "process_steps",
"offices", "gallery_images", "nav_items",
]
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")
)
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()
-201
View File
@@ -35,8 +35,6 @@ class Brand(Base):
class Service(Base): class Service(Base):
__tablename__ = "services" __tablename__ = "services"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
@@ -46,8 +44,6 @@ class Service(Base):
class Project(Base): class Project(Base):
__tablename__ = "projects" __tablename__ = "projects"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
@@ -67,8 +63,6 @@ class Project(Base):
class Article(Base): class Article(Base):
__tablename__ = "articles" __tablename__ = "articles"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
@@ -83,8 +77,6 @@ class Article(Base):
class Testimonial(Base): class Testimonial(Base):
__tablename__ = "testimonials" __tablename__ = "testimonials"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128)) name: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(128), default="") role: Mapped[str] = mapped_column(String(128), default="")
@@ -94,8 +86,6 @@ class Testimonial(Base):
class Faq(Base): class Faq(Base):
__tablename__ = "faqs" __tablename__ = "faqs"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
question: Mapped[str] = mapped_column(Text) question: Mapped[str] = mapped_column(Text)
answer: Mapped[str] = mapped_column(Text, default="") answer: Mapped[str] = mapped_column(Text, default="")
@@ -104,8 +94,6 @@ class Faq(Base):
class TeamMember(Base): class TeamMember(Base):
__tablename__ = "team" __tablename__ = "team"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128)) name: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(128), default="") role: Mapped[str] = mapped_column(String(128), default="")
@@ -115,8 +103,6 @@ class TeamMember(Base):
class TimelineItem(Base): class TimelineItem(Base):
__tablename__ = "timeline" __tablename__ = "timeline"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
year: Mapped[str] = mapped_column(String(32)) year: Mapped[str] = mapped_column(String(32))
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
@@ -127,8 +113,6 @@ class TimelineItem(Base):
class Stat(Base): class Stat(Base):
__tablename__ = "stats" __tablename__ = "stats"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(Integer) value: Mapped[int] = mapped_column(Integer)
suffix: Mapped[str] = mapped_column(String(16), default="") suffix: Mapped[str] = mapped_column(String(16), default="")
@@ -138,8 +122,6 @@ class Stat(Base):
class WhyUsItem(Base): class WhyUsItem(Base):
__tablename__ = "why_us" __tablename__ = "why_us"
position: Mapped[int] = mapped_column(Integer, default=0)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
@@ -157,189 +139,6 @@ class BeforeAfter(Base):
after_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 ContactMessage(Base): class ContactMessage(Base):
__tablename__ = "contact_messages" __tablename__ = "contact_messages"
+4 -316
View File
@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import Literal, Optional from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator from pydantic import BaseModel, ConfigDict, EmailStr, Field
# ---------- پایه ---------- # ---------- پایه ----------
@@ -58,7 +58,7 @@ class ArticleOut(ORMModel):
image: str image: str
read: str read: str
excerpt: str excerpt: str
body: dict body: list[str]
class TestimonialOut(ORMModel): class TestimonialOut(ORMModel):
@@ -112,137 +112,6 @@ class BeforeAfterOut(ORMModel):
after_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 ContactMessageOut(ORMModel): class ContactMessageOut(ORMModel):
id: int id: int
name: str name: str
@@ -318,18 +187,6 @@ class ProjectIn(BaseModel):
body: list[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): class ArticleIn(BaseModel):
slug: str = Field(min_length=1, max_length=128) slug: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
@@ -338,8 +195,7 @@ class ArticleIn(BaseModel):
image: str = "" image: str = ""
read: str = "" read: str = ""
excerpt: str = "" excerpt: str = ""
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) body: list[str] = []
_validate_body = field_validator("body")(validate_doc_body)
class TestimonialIn(BaseModel): class TestimonialIn(BaseModel):
@@ -386,173 +242,5 @@ class BeforeAfterIn(BaseModel):
after_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 MessageReadIn(BaseModel): class MessageReadIn(BaseModel):
is_read: bool = True 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
_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]
+39 -48
View File
@@ -1,49 +1,16 @@
"""اسکریپت seed دیتابیس. """اسکریپت seed دیتابیس.
اجرا: اجرا:
python -m app.seed # فقط جدول‌های خالی پر می‌شوند python -m app.seed # فقط اگر دیتابیس خالی باشد
python -m app.seed --force # حذف کامل داده‌ها و ساخت مجدد python -m app.seed --force # حذف کامل داده‌ها و ساخت مجدد
پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدول‌های جدید یا خالی
seed می‌شوند و داده‌های قبلی دست‌نخورده می‌مانند.
""" """
import argparse import argparse
from typing import Any
from . import models, seed_data from . import models, seed_data
from .auth import hash_password from .auth import hash_password
from .config import settings from .config import settings
from .database import Base, SessionLocal, engine 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], "قبل/بعد"),
]
def seed(force: bool = False) -> None: def seed(force: bool = False) -> None:
@@ -54,19 +21,32 @@ def seed(force: bool = False) -> None:
print("جداول قبلی حذف شدند.") print("جداول قبلی حذف شدند.")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations()
for model, rows, label in CONTENT: if not force and db.query(models.Service).count() > 0:
if db.query(model).count() > 0: print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force")
continue return
for idx, row in enumerate(rows):
payload = dict(row) db.add(models.Brand(**seed_data.BRAND))
if hasattr(model, "position") and "position" not in payload:
payload["position"] = idx for item in seed_data.SERVICES:
# بدنه مقالات در seed به‌صورت لیست پاراگراف است — به سند تبدیل می‌شود db.add(models.Service(**item))
if model is models.Article and isinstance(payload.get("body"), list): for item in seed_data.STATS:
payload["body"] = paragraphs_to_doc(payload["body"]) db.add(models.Stat(**item))
db.add(model(**payload)) for item in seed_data.PROJECTS:
db.add(models.Project(**item))
for item in seed_data.ARTICLES:
db.add(models.Article(**item))
for item in seed_data.TESTIMONIALS:
db.add(models.Testimonial(**item))
for item in seed_data.FAQS:
db.add(models.Faq(**item))
for item in seed_data.TEAM:
db.add(models.TeamMember(**item))
for item in seed_data.TIMELINE:
db.add(models.TimelineItem(**item))
for item in seed_data.WHY:
db.add(models.WhyUsItem(**item))
db.add(models.BeforeAfter(**seed_data.BEFORE_AFTER))
if db.query(models.AdminUser).count() == 0: if db.query(models.AdminUser).count() == 0:
db.add( db.add(
@@ -79,8 +59,19 @@ def seed(force: bool = False) -> None:
db.commit() db.commit()
for model, _, label in CONTENT: counts = {
print(f" {label}: {db.query(model).count()} رکورد") "خدمات": db.query(models.Service).count(),
"پروژه‌ها": db.query(models.Project).count(),
"مقالات": db.query(models.Article).count(),
"نظرات": db.query(models.Testimonial).count(),
"سوالات متداول": db.query(models.Faq).count(),
"تیم": db.query(models.TeamMember).count(),
"تایم‌لاین": db.query(models.TimelineItem).count(),
"آمار": db.query(models.Stat).count(),
"چرا آرامش": db.query(models.WhyUsItem).count(),
}
for label, count in counts.items():
print(f" {label}: {count} رکورد")
print("✔ seed با موفقیت انجام شد.") print("✔ seed با موفقیت انجام شد.")
finally: finally:
db.close() db.close()
+10 -172
View File
@@ -2,9 +2,9 @@
BRAND = { BRAND = {
"name": "گروه ساختمانی آرامش", "name": "گروه ساختمانی آرامش",
"logo": "https://i.postimg.cc/XYNrsHsM/Chat-GPT-Image-Aug-6-2026-04-34-46-PM.png", "logo": "/images/logo.png",
"hero": "https://i.postimg.cc/J0bKgQVg/Gemini-Generated-Image-mdh969mdh969mdh9.png", "hero": "/images/hero.png",
"contact_bg": "https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png", "contact_bg": "/images/contact.png",
"phone": "۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰", "phone": "۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰",
"phone_href": "tel:+982188742210", "phone_href": "tel:+982188742210",
"email": "info@arameshbuildinggroup.com", "email": "info@arameshbuildinggroup.com",
@@ -12,11 +12,11 @@ BRAND = {
} }
IMAGES = [ IMAGES = [
"https://i.postimg.cc/wTQ0QFkG/Gemini-Generated-Image-1tjszd1tjszd1tjs.png", "/images/img1.png",
"https://i.postimg.cc/1zfBPS1G/Gemini-Generated-Image-at69yhat69yhat69.png", "/images/img2.png",
"https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png", "/images/contact.png",
"https://i.postimg.cc/4xFvW0HX/Gemini-Generated-Image-n7mhonn7mhonn7mh.png", "/images/img4.png",
"https://i.postimg.cc/N036cr4s/Gemini-Generated-Image-ytbhvvytbhvvytbh.png", "/images/img5.png",
] ]
SERVICES = [ SERVICES = [
@@ -154,8 +154,8 @@ PROJECTS = [
BEFORE_AFTER = { BEFORE_AFTER = {
"title": "از کانسپت تا خلق واقعیت", "title": "از کانسپت تا خلق واقعیت",
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل می‌شود.", "lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل می‌شود.",
"before": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_before.webp", "before": "/images/before.webp",
"after": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_after.webp", "after": "/images/after.webp",
"before_label": "کانسپت اولیه", "before_label": "کانسپت اولیه",
"after_label": "پروژه تکمیل‌شده", "after_label": "پروژه تکمیل‌شده",
} }
@@ -289,165 +289,3 @@ FAQS = [
{"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"},
],
}
-2
View File
@@ -7,5 +7,3 @@ pydantic-settings>=2.6.1
PyJWT>=2.10.1 PyJWT>=2.10.1
bcrypt>=4.2.1 bcrypt>=4.2.1
email-validator>=2.2.0 email-validator>=2.2.0
boto3>=1.35.0
python-multipart>=0.0.18
+8 -81
View File
@@ -1,17 +1,4 @@
services: services:
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO)
traefik:
image: traefik:v3
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
ports:
- "5173:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
db: db:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped restart: unless-stopped
@@ -27,51 +14,6 @@ services:
timeout: 5s timeout: 5s
retries: 12 retries: 12
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: aramesh
MINIO_ROOT_PASSWORD: aramesh-minio-secret
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
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: aramesh
MINIO_ROOT_PASSWORD: aramesh-minio-secret
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: backend:
build: ./backend build: ./backend
restart: unless-stopped restart: unless-stopped
@@ -81,37 +23,22 @@ services:
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]' CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
ADMIN_USERNAME: admin ADMIN_USERNAME: admin
ADMIN_PASSWORD: aramesh123 ADMIN_PASSWORD: aramesh123
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: aramesh
S3_SECRET_KEY: aramesh-minio-secret
S3_BUCKET: aramesh-media
MEDIA_PUBLIC_BASE: /media
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
minio: ports:
condition: service_healthy - "8000:8000"
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
# فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend: frontend:
build: ./frontend build:
context: ./frontend
args:
VITE_API_URL: ""
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- backend - backend
- minio ports:
labels: - "5173:80"
- 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
volumes: volumes:
db_data: db_data:
minio_data:
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
: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"
}
+19 -6
View File
@@ -1,10 +1,23 @@
# ---------- فرانت‌اند فقط سرو می‌کند ---------- # ---------- مرحله ساخت ----------
# بیلد روی هاست انجام می‌شود (npm install && npm run build) و خروجی dist/ FROM node:22-alpine AS build
# داخل ایمیج کپی می‌شود — شبکه کانتینر بیلد برای npm قابل‌اعتماد نیست.
FROM caddy:2-alpine WORKDIR /app
COPY Caddyfile /etc/caddy/Caddyfile COPY package.json package-lock.json ./
COPY dist /srv RUN npm ci
COPY . .
# آدرس API در زمان بیلد؛ پیش‌فرض خالی یعنی درخواست‌های نسبی (/api) که nginx آن‌ها را به بک‌اند پروکسی می‌کند
ARG VITE_API_URL=""
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# ---------- مرحله سرو ----------
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
+3 -3
View File
@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="fa" dir="rtl"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/images/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>گروه ساختمانی آرامش</title> <title>Aramesh Building Group Luxury Website</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+34
View File
@@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# مسیریابی SPA — همه مسیرها به index.html برمی‌گردند
location / {
try_files $uri $uri/ /index.html;
}
# پروکسی درخواست‌های API به سرویس بک‌اند
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# تصاویر: بدون کش طولانی — تا تغییرات سریع دیده شوند
location ~* ^/images/ {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# کش استاتیک
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
+3 -680
View File
@@ -9,12 +9,6 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.3", "@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", "framer-motion": "^11.5.4",
"lucide-react": "0.522.0", "lucide-react": "0.522.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -928,34 +922,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT",
"optional": true
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -1461,465 +1427,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@tiptap/core": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz",
"integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz",
"integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz",
"integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz",
"integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
"integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz",
"integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz",
"integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
"integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz",
"integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.31.3"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz",
"integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
"integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.31.3"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz",
"integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz",
"integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz",
"integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.31.3.tgz",
"integrity": "sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz",
"integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz",
"integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz",
"integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz",
"integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz",
"integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz",
"integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz",
"integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
"integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
"integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz",
"integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz",
"integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/pm": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz",
"integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.11",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.42.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz",
"integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.31.3",
"@tiptap/extension-floating-menu": "^3.31.3"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
"integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/extension-blockquote": "3.31.3",
"@tiptap/extension-bold": "3.31.3",
"@tiptap/extension-bullet-list": "3.31.3",
"@tiptap/extension-code": "3.31.3",
"@tiptap/extension-code-block": "3.31.3",
"@tiptap/extension-document": "3.31.3",
"@tiptap/extension-dropcursor": "3.31.3",
"@tiptap/extension-gapcursor": "3.31.3",
"@tiptap/extension-hard-break": "3.31.3",
"@tiptap/extension-heading": "3.31.3",
"@tiptap/extension-horizontal-rule": "3.31.3",
"@tiptap/extension-italic": "3.31.3",
"@tiptap/extension-link": "3.31.3",
"@tiptap/extension-list": "3.31.3",
"@tiptap/extension-list-item": "3.31.3",
"@tiptap/extension-list-keymap": "3.31.3",
"@tiptap/extension-ordered-list": "3.31.3",
"@tiptap/extension-paragraph": "3.31.3",
"@tiptap/extension-strike": "3.31.3",
"@tiptap/extension-text": "3.31.3",
"@tiptap/extension-underline": "3.31.3",
"@tiptap/extensions": "3.31.3",
"@tiptap/pm": "3.31.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/static-renderer": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/static-renderer/-/static-renderer-3.31.3.tgz",
"integrity": "sha512-TzbN/aSTQBhGZL94aaEnfbwZw/gIiQd8/q3TucTYuzdbsrsZNfBMg7CCA9W9dOg4vlKxCTLRVas21ivsrUm7og==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1999,12 +1506,14 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.31", "version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -2015,6 +1524,7 @@
"version": "18.3.7", "version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
@@ -2027,12 +1537,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0", "version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
@@ -3074,15 +2578,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz",
"integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -3673,12 +3168,6 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3904,12 +3393,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4238,145 +3721,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
"integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.11",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz",
"integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.3",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4608,12 +3952,6 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5055,15 +4393,6 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -5131,12 +4460,6 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
-6
View File
@@ -11,12 +11,6 @@
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.3", "@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", "framer-motion": "^11.5.4",
"lucide-react": "0.522.0", "lucide-react": "0.522.0",
"react": "^18.3.1", "react": "^18.3.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

+4 -27
View File
@@ -16,9 +16,6 @@ import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage'; import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage'; import { ContactPage } from './pages/ContactPage';
import { SiteProvider, useSiteData } from './api'; import { SiteProvider, useSiteData } from './api';
import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage';
function ScrollToTop() { function ScrollToTop() {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -40,7 +37,6 @@ function AnimatedRoutes() {
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}> transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
<Routes location={location}> <Routes location={location}>
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="/services" element={<ServicesPage />} /> <Route path="/services" element={<ServicesPage />} />
@@ -58,29 +54,9 @@ function AnimatedRoutes() {
} }
function AppChrome() {
const { pathname } = useLocation();
const { isAuthenticated } = useAdmin();
const isLoginPage = pathname === '/admin/login';
return (
<>
{!isLoginPage && <Header />}
<AnimatedRoutes />
{!isLoginPage && <Footer />}
{!isLoginPage && <FloatingContact />}
{isAuthenticated && <AdminBar />}
</>
);
}
function AppShell() { function AppShell() {
const { data, error, reload } = useSiteData(); const { data, error, reload } = useSiteData();
useEffect(() => {
if (data?.brand.name) document.title = data.brand.name;
}, [data?.brand.name]);
return ( return (
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory"> <div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
<Preloader /> <Preloader />
@@ -101,7 +77,10 @@ function AppShell() {
) : data ? ( ) : data ? (
<BrowserRouter> <BrowserRouter>
<ScrollToTop /> <ScrollToTop />
<AppChrome /> <Header />
<AnimatedRoutes />
<Footer />
<FloatingContact />
</BrowserRouter> </BrowserRouter>
) : null} ) : null}
</div> </div>
@@ -116,9 +95,7 @@ export function App() {
return ( return (
<SiteProvider> <SiteProvider>
<AdminProvider>
<AppShell /> <AppShell />
</AdminProvider>
</SiteProvider> </SiteProvider>
); );
} }
-64
View File
@@ -1,64 +0,0 @@
import { useAdmin } from './AdminContext'
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',
}
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
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={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>
)
}
-157
View File
@@ -1,157 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import Swal from 'sweetalert2'
import type { SiteData } from '../api'
import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient'
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)
}, [])
const logout = useCallback(() => {
setToken(null)
setIsAuthenticated(false)
setIsEditMode(false)
sessionStorage.removeItem('aramesh_edit_mode')
}, [])
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 target = e.target as HTMLElement | null
if (target?.closest('a')) 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
@@ -1,85 +0,0 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useAdmin } from './AdminContext'
/** صفحه ورود ادمین — مسیر پنهان /admin/login */
export function AdminLoginPage() {
const { isAuthenticated, login } = useAdmin()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
if (isAuthenticated) return <Navigate to="/" replace />
const submit = async (e: FormEvent) => {
e.preventDefault()
if (submitting) return
setError('')
setSubmitting(true)
try {
await login(username.trim(), password)
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
} finally {
setSubmitting(false)
}
}
return (
<section className="flex min-h-screen flex-col items-center justify-center bg-ink px-6" dir="rtl">
<div className="w-full max-w-sm border border-ivory/10 bg-char p-10">
<h1 className="text-xl font-bold text-ivory">ورود مدیریت</h1>
<p className="mt-3 text-sm font-light leading-7 text-ivory/55">
برای فعال‌سازی حالت ویرایش زنده سایت وارد شوید.
</p>
<form onSubmit={submit} className="mt-8 grid gap-5" noValidate>
<div>
<label htmlFor="admin-username" className="block text-sm font-bold text-ivory/90">
نام کاربری
</label>
<input
id="admin-username"
type="text"
dir="ltr"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="admin-password" className="block text-sm font-bold text-ivory/90">
رمز عبور
</label>
<input
id="admin-password"
type="password"
dir="ltr"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
/>
</div>
{error && (
<p className="border-r-2 border-red-400/70 pr-3 text-xs font-light leading-6 text-red-300">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="mt-2 bg-copper px-8 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-[1.02] disabled:opacity-50"
>
{submitting ? 'در حال ورود…' : 'ورود'}
</button>
</form>
</div>
</section>
)
}
-208
View File
@@ -1,208 +0,0 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import Swal from 'sweetalert2'
import { useSiteData } 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 = '',
}: {
resource: string
item: ItemWithId
children: ReactNode
className?: 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">
<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 }: { resource: string }) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const { reload } = 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()
setNewItem(created)
})
}
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>
{newItem && (
<EntityDrawer config={config} item={newItem} deletable onClose={() => setNewItem(null)} />
)}
</>
)
}
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش */
export function SingletonPill({
resource,
label,
className = '',
}: {
resource: string
label: string
className?: string
}) {
const { isAuthenticated, isEditMode } = useAdmin()
const config = ENTITIES[resource]
const { data } = useSiteData()
const [drawerOpen, setDrawerOpen] = useState(false)
if (!config || !isAuthenticated || !isEditMode) return null
const item = data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined
if (!item) return null
return (
<>
<span className={className}>
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
</span>
{drawerOpen && <EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />}
</>
)
}
-339
View File
@@ -1,339 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient'
import 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>
)
}
-86
View File
@@ -1,86 +0,0 @@
// لایه تماس ادمین — همه درخواست‌ها با توکن Bearer و مدیریت خطای یکسان
import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token'
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string | null): void => {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
}
}
async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const isFormData = options.body instanceof FormData
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: {
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.body && !isFormData ? { 'Content-Type': 'application/json' } : {}),
},
})
if (res.status === 401) {
setToken(null)
throw new HttpError(401, 'نشست شما منقضی شده — دوباره وارد شوید')
}
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
const detail =
typeof data?.detail === 'string'
? data.detail
: res.status === 409
? 'رکوردی با این مقدار تکراری وجود دارد'
: 'خطا در ذخیره‌سازی — دوباره تلاش کنید'
throw new HttpError(res.status, detail)
}
return res.status === 204 ? (null as T) : ((await res.json()) as T)
}
export const adminApi = {
login: async (username: string, password: string): Promise<string> => {
const res = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
throw new HttpError(
res.status,
typeof data?.detail === 'string' ? data.detail : 'ورود ناموفق بود',
)
}
const data = (await res.json()) as { access_token: string }
setToken(data.access_token)
return data.access_token
},
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
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 })
},
}
-187
View File
@@ -1,187 +0,0 @@
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('slug', 'نشانی (slug)'), F('date', 'تاریخ'), F('category', 'دسته'),
F('image', 'تصویر شاخص', 'image'), F('read', 'زمان مطالعه'), F('excerpt', 'چکیده', 'textarea'),
F('body', 'متن مقاله', 'wysiwyg'),
],
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: '/' },
},
// ---------- رکوردهای یگانه ----------
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
}
+3 -163
View File
@@ -32,109 +32,8 @@ export type BeforeAfterInfo = {
after_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 { export interface SiteData {
brand: BrandInfo brand: BrandInfo
nav: NavItem[]
hero: HeroContent
story: StoryContent
section_headings: SectionHeadingContent[]
page_heroes: PageHeroContent[]
services: Service[] services: Service[]
projects: Project[] projects: Project[]
articles: Article[] articles: Article[]
@@ -145,30 +44,15 @@ export interface SiteData {
stats: StatItem[] stats: StatItem[]
why_us: WhyUsItem[] why_us: WhyUsItem[]
before_after: BeforeAfterInfo before_after: BeforeAfterInfo
investment_models: InvestmentModelItem[]
process_steps: ProcessStepItem[]
offices: OfficeItem[]
gallery: GalleryImageItem[]
status_labels: StatusLabelItem[]
contact_section: ContactSectionContent
newsletter: NewsletterContent
footer: FooterContent
} }
interface SiteContextValue { interface SiteContextValue {
data: SiteData | null data: SiteData | null
error: boolean error: boolean
reload: () => void reload: () => void
/** به‌روزرسانی خوش‌بینانه درخت داده — پس از موفقیت PATCH استفاده می‌شود */
updateSite: (mutate: (data: SiteData) => void) => void
} }
const SiteContext = createContext<SiteContextValue>({ const SiteContext = createContext<SiteContextValue>({ data: null, error: false, reload: () => {} })
data: null,
error: false,
reload: () => {},
updateSite: () => {},
})
export function SiteProvider({ children }: { children: ReactNode }) { export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null) const [data, setData] = useState<SiteData | null>(null)
@@ -184,7 +68,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
}) })
.then((json) => { .then((json) => {
if (!cancelled) { if (!cancelled) {
if (json.brand && json.hero && json.footer && json.nav) setData(json) if (json.brand && json.before_after) setData(json)
else setError(true) else setError(true)
} }
}) })
@@ -204,20 +88,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
load() load()
}, [load]) }, [load])
const updateSite = useCallback((mutate: (data: SiteData) => void) => { return <SiteContext.Provider value={{ data, error, reload }}>{children}</SiteContext.Provider>
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 { export function useSiteData(): SiteContextValue {
@@ -232,34 +103,3 @@ export function useSite(): SiteData {
} }
return data 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
}
+21 -50
View File
@@ -4,28 +4,14 @@ import Swal from 'sweetalert2'
import { Reveal } from './Reveal' import { Reveal } from './Reveal'
import { Button } from './Button' import { Button } from './Button'
import { useSite } from '../api' import { useSite } from '../api'
import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit'
import { SingletonPill } from '../admin/Editable'
import { API_URL } from '../config' import { API_URL } from '../config'
export function ContactSection() { export function ContactSection() {
const { brand, contact_section: content } = useSite() const { brand } = useSite()
const save = useFieldSaver()
const [form, setForm] = useState({ name: '', phone: '', note: '' }) const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
const [sending, setSending] = 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) => { const showErrorAlert = (title: string, text: string) => {
Swal.fire({ Swal.fire({
icon: 'warning', icon: 'warning',
@@ -110,14 +96,12 @@ export function ContactSection() {
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5"> <div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
<Reveal> <Reveal>
<p className="text-sm font-light text-copper"> <p className="text-sm font-light text-copper">تماس با ما</p>
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
</p>
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory"> <h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} /> پروژه‌تان را با ما در میان بگذارید
</h2> </h2>
<p className="mt-6 text-base font-light leading-9 text-ivory/75"> <p className="mt-6 text-base font-light leading-9 text-ivory/75">
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} /> شماره‌تان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس می‌گیرد.
</p> </p>
</Reveal> </Reveal>
@@ -126,41 +110,33 @@ export function ContactSection() {
<li className="flex items-start gap-4"> <li className="flex items-start gap-4">
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" /> <MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div> <div>
<p className="text-sm font-bold text-ivory"> <p className="text-sm font-bold text-ivory">دفتر مرکزی</p>
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} /> <p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
</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> </div>
</li> </li>
<li className="flex items-start gap-4"> <li className="flex items-start gap-4">
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" /> <PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div> <div>
<p className="text-sm font-bold text-ivory"> <p className="text-sm font-bold text-ivory">تلفن</p>
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
</p>
<a <a
href={brand.phone_href} href={brand.phone_href}
dir="ltr" dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper" className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
> >
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} /> {brand.phone}
</a> </a>
</div> </div>
</li> </li>
<li className="flex items-start gap-4"> <li className="flex items-start gap-4">
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" /> <MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div> <div>
<p className="text-sm font-bold text-ivory"> <p className="text-sm font-bold text-ivory">ایمیل</p>
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
</p>
<a <a
href={`mailto:${brand.email}`} href={`mailto:${brand.email}`}
dir="ltr" dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper" className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
> >
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} /> {brand.email}
</a> </a>
</div> </div>
</li> </li>
@@ -168,16 +144,13 @@ export function ContactSection() {
</Reveal> </Reveal>
</div> </div>
<div className="relative border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7"> <div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
<SingletonPill resource="contact_section" label="ویرایش برچسب‌ها و متن‌های فرم" className="absolute left-4 top-4" />
{sent ? ( {sent ? (
<div className="flex h-full flex-col items-center justify-center py-14 text-center"> <div className="flex h-full flex-col items-center justify-center py-14 text-center">
<CheckCircle2Icon className="h-12 w-12 text-copper" /> <CheckCircle2Icon className="h-12 w-12 text-copper" />
<h3 className="mt-6 text-xl font-bold text-ivory"> <h3 className="mt-6 text-xl font-bold text-ivory">درخواست شما ثبت شد</h3>
<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"> <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> </p>
<Button <Button
type="button" type="button"
@@ -188,28 +161,28 @@ export function ContactSection() {
setForm({ name: '', phone: '', note: '' }) setForm({ name: '', phone: '', note: '' })
}} }}
> >
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} /> ثبت درخواست جدید
</Button> </Button>
</div> </div>
) : ( ) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2"> <form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
<div> <div>
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90"> <label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} /> نام
</label> </label>
<input <input
id="contact-name" id="contact-name"
type="text" type="text"
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={content.name_placeholder} placeholder="نام و نام خانوادگی"
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none" className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/> />
</div> </div>
<div> <div>
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90"> <label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span> شماره تلفن <span className="text-copper">*</span>
</label> </label>
<input <input
id="contact-phone" id="contact-phone"
@@ -218,30 +191,28 @@ export function ContactSection() {
required required
value={form.phone} value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })} onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder={content.phone_placeholder} placeholder="09xx xxx xxxx"
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none" className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90"> <label htmlFor="contact-note" className="block text-sm font-bold 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> توضیحات <span className="font-light text-ivory/50">(اختیاری)</span>
</label> </label>
<textarea <textarea
id="contact-note" id="contact-note"
rows={6} rows={6}
value={form.note} value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })} onChange={(e) => setForm({ ...form, note: e.target.value })}
placeholder={content.note_placeholder} placeholder="درباره پروژه یا درخواست خود بنویسید…"
className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none" className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}> <Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
{sending ? content.sending_label : ( {sending ? 'در حال ارسال…' : 'ارسال درخواست مشاوره'}
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
)}
</Button> </Button>
</div> </div>
</form> </form>
+21 -61
View File
@@ -1,94 +1,60 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'; import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { NAV } from '../data/site';
import { useSite } from '../api'; 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() { export function Footer() {
const { brand, services, nav, footer } = useSite(); const { brand, services } = 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);
});
return ( return (
<footer className="border-t border-ivory/10 bg-char"> <footer className="border-t border-ivory/10 bg-char">
<div className="mx-auto max-w-site px-6 py-16 lg:px-10"> <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="grid gap-12 lg:grid-cols-12">
<div className="lg:col-span-4"> <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" /> <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/55"> <p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} /> گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمان‌هایی می‌سازد که قرار است چند نسل در آن‌ها زندگی کنند؛ معماری آرام،
اجرای دقیق و سرمایه‌گذاری شفاف.
</p> </p>
<div className="mt-6 flex gap-3"> <div className="mt-6 flex gap-3">
{footer.socials.map(({ label, href, icon }) => { {[
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon; { Icon: InstagramIcon, label: 'اینستاگرام' },
return ( { Icon: LinkedinIcon, label: 'لینکدین' },
{ Icon: SendIcon, label: 'تلگرام' }].
map(({ Icon, label }) =>
<a <a
key={label} key={label}
href={href} href="#"
aria-label={label} aria-label={label}
className="flex h-10 w-10 items-center justify-center border border-ivory/15 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"> className="flex h-10 w-10 items-center justify-center border border-ivory/15 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</a> </a>
); )}
})}
</div> </div>
</div> </div>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<h3 className="text-sm font-bold text-copper"> <h3 className="text-sm font-bold text-copper">صفحات</h3>
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
</h3>
<ul className="mt-5 space-y-3"> <ul className="mt-5 space-y-3">
{nav.map((item) => {NAV.map((item) =>
<EditableItem key={item.id} resource="nav" item={item}> <li key={item.to}>
<li>
<Link <Link
to={item.href} to={item.to}
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"> className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
<InlineEdit {item.label}
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
</Link> </Link>
</li> </li>
</EditableItem>
)} )}
</ul> </ul>
<div className="mt-4">
<AddItemButton resource="nav" />
</div>
</div> </div>
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper"> <h3 className="text-sm font-bold text-copper">خدمات</h3>
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
</h3>
<ul className="mt-5 space-y-3"> <ul className="mt-5 space-y-3">
{services.map((service) => {services.map((service) =>
<li key={service.id} className="text-sm font-light text-ivory/60"> <li key={service.title} className="text-sm font-light text-ivory/60">
{service.title} {service.title}
</li> </li>
)} )}
@@ -96,9 +62,7 @@ export function Footer() {
</div> </div>
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper"> <h3 className="text-sm font-bold text-copper">ارتباط با ما</h3>
<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"> <ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" /> <MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
@@ -121,12 +85,8 @@ export function Footer() {
</div> </div>
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row"> <div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
<p> <p>© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.</p>
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} /> <p>طراحی و ساخت با دقتِ معماری</p>
</p>
<p>
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
</p>
</div> </div>
</div> </div>
</footer>); </footer>);
+11 -32
View File
@@ -1,20 +1,12 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom"; import { Link, NavLink, useLocation } from "react-router-dom";
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react"; import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
import { NAV } from "../data/site";
import { useSite } from "../api"; import { useSite } from "../api";
import { useFieldSaver } from "../admin/AdminContext"; import { use } from "framer-motion/m";
import { InlineEdit } from "./admin/InlineEdit";
import { EditableItem, SingletonPill } from "../admin/Editable";
export function Header() { export function Header() {
const { brand, nav } = useSite(); const { brand } = 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 [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -38,7 +30,6 @@ export function Header() {
}`} }`}
> >
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10"> <div className="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 <Link
to="/" to="/"
className="flex items-center gap-3" className="flex items-center gap-3"
@@ -52,16 +43,13 @@ export function Header() {
}`} }`}
/> />
</Link> </Link>
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
</div>
<nav aria-label="ناوبری اصلی" className="hidden xl:block"> <nav aria-label="ناوبری اصلی" className="hidden xl:block">
<ul className="flex items-center gap-7"> <ul className="flex items-center gap-7">
{nav.map((item) => ( {NAV.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}> <li key={item.to}>
<li>
<NavLink <NavLink
to={item.href} to={item.to}
className={({ isActive }) => 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)] ${ `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" : "text-ivory/80" isActive ? "text-copper" : "text-ivory/80"
@@ -70,10 +58,7 @@ export function Header() {
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
<InlineEdit {item.label}
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
<span <span
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${ className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
isActive isActive
@@ -85,7 +70,6 @@ export function Header() {
)} )}
</NavLink> </NavLink>
</li> </li>
</EditableItem>
))} ))}
</ul> </ul>
</nav> </nav>
@@ -126,24 +110,19 @@ export function Header() {
className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden" className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden"
> >
<ul className="grid gap-1"> <ul className="grid gap-1">
{nav.map((item) => ( {NAV.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}> <li key={item.to}>
<li>
<NavLink <NavLink
to={item.href} to={item.to}
className={({ isActive }) => className={({ isActive }) =>
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${ `block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
isActive ? "text-copper" : "text-ivory/80" isActive ? "text-copper" : "text-ivory/80"
}` }`
} }
> >
<InlineEdit {item.label}
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
</NavLink> </NavLink>
</li> </li>
</EditableItem>
))} ))}
</ul> </ul>
</nav> </nav>
+10 -38
View File
@@ -2,29 +2,17 @@ import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { ChevronLeftIcon } from 'lucide-react' import { ChevronLeftIcon } from 'lucide-react'
import { usePageHero } from '../api'
import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit'
import { ImageField } from './admin/ImageField'
type PageHeroProps = { type PageHeroProps = {
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایش‌پذیر می‌شوند */ title: string
pageKey: string lead: string
image: string
} }
export function PageHero({ pageKey }: PageHeroProps) { export function PageHero({ title, lead, image }: 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 ( return (
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden"> <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" />} <img src={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 bg-ink/50" aria-hidden="true" />
@@ -40,41 +28,25 @@ export function PageHero({ pageKey }: PageHeroProps) {
صفحه اصلی صفحه اصلی
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5" /> <ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{hero.title}</span> <span className="text-copper">{title}</span>
</nav> </nav>
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 24 }} initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-[700px]" className="mt-5 max-w-[700px] text-hero font-bold text-ivory"
> >
<InlineEdit {title}
as="span"
value={hero.title}
onSave={(v) => saveField('title', v)}
className="block text-hero font-bold text-ivory"
/>
</motion.h1> </motion.h1>
<motion.p <motion.p
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-2xl" className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/65"
> >
<InlineEdit {lead}
as="span"
multiline
value={hero.lead}
onSave={(v) => saveField('lead', v)}
className="block text-base font-light leading-9 text-ivory/65"
/>
</motion.p> </motion.p>
<div className="mt-8 h-px w-28 bg-copper" /> <div className="mt-8 h-px w-28 bg-copper" />
{/* ویرایش تصویر پس‌زمینه — فقط در حالت ویرایش ادمین */}
<div className="mt-6 [&>div]:!mt-0">
<ImageField label="تصویر پس‌زمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
</div>
</div> </div>
</section> </section>
) )
+4 -12
View File
@@ -1,19 +1,11 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useSiteData } from '../api' import { BRAND } from '../data/site'
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری داده‌ها از دیتابیس استفاده می‌شود
const FALLBACK_LOGO = '/images/logo.png'
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
export function Preloader() { export function Preloader() {
const { data } = useSiteData()
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
const logo = data?.brand.logo || FALLBACK_LOGO
const name = data?.brand.name || FALLBACK_NAME
useEffect(() => { useEffect(() => {
const start = Date.now() const start = Date.now()
const total = 1800 const total = 1800
@@ -47,13 +39,13 @@ export function Preloader() {
> >
<div className="relative h-36 w-36"> <div className="relative h-36 w-36">
<img <img
src={logo} src={BRAND.logo}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]" 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)` }}> <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" /> <img src={BRAND.logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
</div> </div>
<div <div
className="absolute inset-x-0 h-px bg-copper" className="absolute inset-x-0 h-px bg-copper"
@@ -72,7 +64,7 @@ export function Preloader() {
transition={{ duration: 0.5, delay: 0.2 }} transition={{ duration: 0.5, delay: 0.2 }}
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar" className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
> >
{name} {BRAND.name}
</motion.p> </motion.p>
</motion.div> </motion.div>
)} )}
+1 -2
View File
@@ -1,11 +1,10 @@
import React from 'react' import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useStatusLabel } from '../api'
export function ProjectCard({ project }: any) { export function ProjectCard({ project }: any) {
const isOngoing = project.status === 'progress' const isOngoing = project.status === 'progress'
const statusText = useStatusLabel(project.status) const statusText = isOngoing ? 'در حال انجام' : 'انجام شده'
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60' const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90' const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
+5 -34
View File
@@ -1,56 +1,27 @@
import React from 'react' import React from 'react'
import { Reveal } from './Reveal' import { Reveal } from './Reveal'
import { useSectionHeading } from '../api'
import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit'
type SectionHeadingProps = { type SectionHeadingProps = {
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و به‌صورت درجا ویرایش‌پذیر می‌شوند */ title: string
headingKey?: string
title?: string
lead?: string lead?: string
align?: 'right' | 'center' align?: 'right' | 'center'
tone?: 'copper' | 'ivory' tone?: 'copper' | 'ivory'
} }
export function SectionHeading({ export function SectionHeading({
headingKey,
title, title,
lead, lead,
align = 'right', align = 'right',
tone = 'ivory', tone = 'ivory',
}: SectionHeadingProps) { }: 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 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 ( return (
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}> <Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
<InlineEdit <h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
as="h2" {title}
value={currentTitle} </h2>
onSave={(v) => saveField('title', v)}
className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}
/>
{currentLead && ( {lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
<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' : ''}`} /> <div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
</Reveal> </Reveal>
@@ -1,267 +0,0 @@
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>
)
}
@@ -1,81 +0,0 @@
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>
)
}
@@ -1,87 +0,0 @@
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>
)
}
+7 -28
View File
@@ -3,61 +3,40 @@ import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal'; import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button'; import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard'; import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api'; import { useSite } from '../../api';
import { useFieldSaver } from '../../admin/AdminContext';
import { InlineEdit } from '../admin/InlineEdit';
import { AddItemButton, EditableItem } from '../../admin/Editable';
export function Articles() { export function Articles() {
const { articles } = useSite(); const { articles } = useSite();
const heading = useSectionHeading('articles');
const save = useFieldSaver();
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 ( return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right"> <Reveal className="max-w-2xl text-right">
<p className="text-sm font-light text-copper"> <p className="text-sm font-light text-copper">وبلاگ</p>
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
</p>
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory"> <h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} /> آخرین مقالات
</h2> </h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60"> <p className="mt-5 text-base font-light leading-9 text-ivory/60">
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} /> یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار می‌گذرد.
</p> </p>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal> </Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-3"> <div className="mt-14 grid gap-6 lg:grid-cols-3">
{articles.slice(0, 3).map((article, i) => {articles.slice(0, 3).map((article, i) =>
<Reveal key={article.id} delay={0.06 * i} className="h-full"> <Reveal key={article.slug} delay={0.06 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full">
<ArticleCard article={article} /> <ArticleCard article={article} />
</EditableItem>
</Reveal> </Reveal>
)} )}
</div> </div>
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<ButtonLink to={heading.cta_href} variant="outline" className="font-light"> <ButtonLink to="/blog" variant="outline" className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} /> همه مقالات
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
</ButtonLink> </ButtonLink>
</div> </div>
</Reveal> </Reveal>
)}
<div className="mt-6">
<AddItemButton resource="articles" />
</div>
</div> </div>
</section>); </section>);
@@ -3,7 +3,6 @@ import { MoveHorizontalIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { SingletonPill } from '../../admin/Editable'
export function BeforeAfter() { export function BeforeAfter() {
const { before_after } = useSite() const { before_after } = useSite()
@@ -55,10 +54,6 @@ export function BeforeAfter() {
align="center" align="center"
/> />
<div className="flex justify-start">
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسب‌های قبل/بعد" />
</div>
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div <div
ref={containerRef} ref={containerRef}
+1 -8
View File
@@ -4,7 +4,6 @@ import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { AddItemButton, EditableItem } from '../../admin/Editable'
type FaqProps = { type FaqProps = {
hideHeading?: boolean; hideHeading?: boolean;
@@ -24,7 +23,6 @@ export function Faq({ hideHeading = false }: FaqProps) {
<SectionHeading <SectionHeading
title="سوالات متداول" title="سوالات متداول"
lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران." lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."
headingKey="faq"
/> />
</div> </div>
)} )}
@@ -34,8 +32,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
{faqs.map((faq, i) => { {faqs.map((faq, i) => {
const isOpen = open === i const isOpen = open === i
return ( return (
<Reveal key={faq.id} delay={0.04 * i}> <Reveal key={faq.question} delay={0.04 * i}>
<EditableItem resource="faqs" item={faq}>
<li className="border-b border-ivory/10"> <li className="border-b border-ivory/10">
<button <button
type="button" type="button"
@@ -66,14 +63,10 @@ export function Faq({ hideHeading = false }: FaqProps) {
)} )}
</AnimatePresence> </AnimatePresence>
</li> </li>
</EditableItem>
</Reveal> </Reveal>
) )
})} })}
</ul> </ul>
<div className="mt-8">
<AddItemButton resource="faqs" />
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -5,36 +5,26 @@ import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button' import { ButtonLink } from '../Button'
import { ProjectCard } from '../ProjectCard' import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api' import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site' 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 }[] = [ const FILTERS: { key: ProjectStatus; label: string }[] = [
{ key: 'done', label: useStatusLabel('done') }, { key: 'done', label: 'انجام شده' },
{ key: 'progress', label: useStatusLabel('progress') }, { key: 'progress', label: 'در حال انجام' },
] ]
export function FeaturedProjects() {
const { projects } = useSite()
const [status, setStatus] = useState<ProjectStatus>('done') const [status, setStatus] = useState<ProjectStatus>('done')
const list = projects.filter((p) => p.status === status).slice(0, 3) const list = projects.filter((p) => p.status === status).slice(0, 3)
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" /> <SectionHeading
title="پروژه‌های شاخص"
lead="کارهایی که بیشترین چیز را درباره استاندارد ما می‌گویند؛ چه آن‌هایی که تحویل شده‌اند و چه آن‌هایی که همین امروز در حال ساخت‌اند."
/>
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه"> <div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter) => ( {FILTERS.map((filter) => (
@@ -57,10 +47,7 @@ export function FeaturedProjects() {
/> />
)} )}
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char"> <span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
<InlineEdit {filter.label}
value={filter.label}
onSave={(v) => saveStatusLabel(filter.key, v)}
/>
</span> </span>
</button> </button>
))} ))}
@@ -78,39 +65,27 @@ export function FeaturedProjects() {
> >
{list.map((project, i) => ( {list.map((project, i) => (
<motion.div <motion.div
key={project.id} key={project.slug}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full" className="h-full"
> >
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} /> <ProjectCard project={project} />
</EditableItem>
</motion.div> </motion.div>
))} ))}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<ButtonLink to={heading.cta_href} variant="outline" className="font-light"> <ButtonLink to="/projects" variant="outline" className="font-light">
<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
})
}
/>
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
</ButtonLink> </ButtonLink>
</div> </div>
</Reveal> </Reveal>
)}
</div> </div>
</section> </section>
) )
+11 -15
View File
@@ -3,17 +3,17 @@ import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react' import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { IMAGES } from '../../data/site'
import { AddItemButton, EditableItem } from '../../admin/Editable'
const GRID = [IMAGES[0], IMAGES[1], IMAGES[2], IMAGES[3], IMAGES[4], IMAGES[1]]
export function Gallery() { export function Gallery() {
const { gallery } = useSite()
const [active, setActive] = useState<number | null>(null) const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), []) const close = useCallback(() => setActive(null), [])
const step = useCallback((dir: number) => { const step = useCallback((dir: number) => {
setActive((prev) => (prev === null ? prev : (prev + dir + gallery.length) % gallery.length)) setActive((prev) => (prev === null ? prev : (prev + dir + GRID.length) % GRID.length))
}, [gallery.length]) }, [])
useEffect(() => { useEffect(() => {
if (active === null) return if (active === null) return
@@ -32,14 +32,12 @@ export function Gallery() {
<SectionHeading <SectionHeading
title="گالری تصاویر" title="گالری تصاویر"
lead="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش." lead="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش."
headingKey="gallery"
align="center" align="center"
/> />
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3"> <div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
{gallery.map((image, i) => ( {GRID.map((src, i) => (
<Reveal key={image.id} delay={0.04 * i}> <Reveal key={`${src}-${i}`} delay={0.04 * i}>
<EditableItem resource="gallery" item={image}>
<button <button
type="button" type="button"
onClick={() => setActive(i)} onClick={() => setActive(i)}
@@ -47,20 +45,18 @@ export function Gallery() {
aria-label={`بزرگ‌نمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`} aria-label={`بزرگ‌نمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
> >
<img <img
src={image.src} src={src}
alt={image.alt} alt={`تصویری از پروژه‌های گروه ساختمانی آرامش، شماره ${(i + 1).toLocaleString('fa-IR')}`}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/> />
</button> </button>
</EditableItem>
</Reveal> </Reveal>
))} ))}
<AddItemButton resource="gallery" />
</div> </div>
</div> </div>
<AnimatePresence> <AnimatePresence>
{active !== null && gallery[active] && ( {active !== null && (
<motion.div <motion.div
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6" className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -106,7 +102,7 @@ export function Gallery() {
<motion.img <motion.img
key={active} key={active}
src={gallery[active].src} src={GRID[active]}
alt="" alt=""
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
+14 -45
View File
@@ -3,38 +3,14 @@ import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react' import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
export function Hero() { export function Hero() {
const { brand, hero } = useSite() const { brand } = useSite()
const save = useFieldSaver()
const { scrollY } = useScroll() const { scrollY } = useScroll()
const imageY = useTransform(scrollY, [0, 700], [0, 110]) const imageY = useTransform(scrollY, [0, 700], [0, 110])
const contentY = useTransform(scrollY, [0, 700], [0, -55]) const contentY = useTransform(scrollY, [0, 700], [0, -55])
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78]) 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
})
// واژه highlight در میانه تیتر با رنگ مسی رندر می‌شود
const renderTitle = () => {
if (hero.title_highlight && hero.title.includes(hero.title_highlight)) {
const [before, ...rest] = hero.title.split(hero.title_highlight)
return (
<>
{before}
<span className="text-copper">{hero.title_highlight}</span>
{rest.join(hero.title_highlight)}
</>
)
}
return hero.title
}
return ( return (
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش"> <section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
<motion.img <motion.img
@@ -64,7 +40,7 @@ export function Hero() {
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
className="text-sm font-light text-copper" className="text-sm font-light text-copper"
> >
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} /> از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال
</motion.p> </motion.p>
<motion.h1 <motion.h1
@@ -73,7 +49,9 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory" className="mt-6 text-hero font-bold text-ivory"
> >
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" /> ساختمانی که آرامش را
<span className="text-copper"> معماری </span>
می‌کند
</motion.h1> </motion.h1>
<motion.p <motion.p
@@ -82,7 +60,8 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }} 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/70" className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/70"
> >
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} /> طراحی، ساخت و سرمایه‌گذاری در پروژه‌های مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده می‌شود و در
سال‌ها دوام می‌آورد.
</motion.p> </motion.p>
<motion.div <motion.div
@@ -91,12 +70,12 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4" className="mt-10 flex flex-wrap items-center gap-4"
> >
<ButtonLink to={hero.cta_primary_href} variant="copper"> <ButtonLink to="/projects" variant="copper">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} /> مشاهده پروژه‌ها
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
</ButtonLink> </ButtonLink>
<ButtonLink to={hero.cta_secondary_href} variant="outline" className="font-light"> <ButtonLink to="/contact" variant="outline" className="font-light">
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} /> مشاوره سرمایه‌گذاری
</ButtonLink> </ButtonLink>
</motion.div> </motion.div>
</div> </div>
@@ -107,11 +86,9 @@ export function Hero() {
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 left-6 hidden w-60 border border-ivory/15 bg-char/60 p-6 backdrop-blur-md lg:block lg:left-10" className="absolute bottom-28 left-6 hidden w-60 border border-ivory/15 bg-char/60 p-6 backdrop-blur-md lg:block lg:left-10"
> >
<p className="text-3xl font-bold text-copper"> <p className="text-3xl font-bold text-copper">۴۷</p>
<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"> <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> </p>
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -121,17 +98,9 @@ export function Hero() {
aria-label="رفتن به بخش بعدی" aria-label="رفتن به بخش بعدی"
className="absolute bottom-8 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/50 transition-colors duration-200 ease-lux hover:text-copper" className="absolute bottom-8 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/50 transition-colors duration-200 ease-lux hover:text-copper"
> >
<span className="text-xs font-light"> <span className="text-xs font-light">اسکرول کنید</span>
<InlineEdit value={hero.scroll_label} onSave={(v) => saveHero('scroll_label', v)} />
</span>
<ChevronDownIcon className="h-4 w-4 animate-bounce" /> <ChevronDownIcon className="h-4 w-4 animate-bounce" />
</a> </a>
<SingletonPill
resource="hero"
label="تنظیمات هیرو (واژه مسی، دکمه‌ها)"
className="absolute left-6 top-24 z-20 lg:left-10"
/>
</section> </section>
) )
} }
+7 -21
View File
@@ -3,24 +3,13 @@ import { CheckIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { Button } from '../Button' 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' import { API_URL } from '../../config'
export function Newsletter() { export function Newsletter() {
const { newsletter } = useSite()
const save = useFieldSaver()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle') const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
const [sending, setSending] = useState(false) 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) => { const showErrorAlert = (title: string, text: string) => {
Swal.fire({ Swal.fire({
icon: 'warning', icon: 'warning',
@@ -96,15 +85,14 @@ export function Newsletter() {
} }
return ( return (
<section className="relative bg-copper py-16" aria-labelledby="newsletter-title"> <section className="bg-copper py-16" aria-labelledby="newsletter-title">
<SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" />
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10"> <div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
<Reveal> <Reveal>
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl"> <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> </h2>
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75"> <p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
<InlineEdit multiline value={newsletter.lead} onSave={(v) => saveContent('lead', v)} /> هر ماه یک ایمیل: پروژه‌های جدید، فرصت‌های سرمایه‌گذاری و یادداشت‌های تیم معماری.
</p> </p>
</Reveal> </Reveal>
@@ -112,7 +100,7 @@ export function Newsletter() {
<form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row" noValidate> <form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row" noValidate>
<div className="flex-1"> <div className="flex-1">
<label htmlFor="newsletter-email" className="sr-only"> <label htmlFor="newsletter-email" className="sr-only">
{newsletter.placeholder} نشانی ایمیل
</label> </label>
<input <input
id="newsletter-email" id="newsletter-email"
@@ -123,23 +111,21 @@ export function Newsletter() {
setEmail(e.target.value) setEmail(e.target.value)
if (status !== 'idle') setStatus('idle') if (status !== 'idle') setStatus('idle')
}} }}
placeholder={newsletter.placeholder} placeholder="نشانی ایمیل شما"
className="w-full border border-transparent bg-char px-5 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 transition-all duration-300 ease-lux focus:border-ivory focus:outline-none" className="w-full border border-transparent bg-char px-5 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 transition-all duration-300 ease-lux focus:border-ivory focus:outline-none"
aria-invalid={status === 'error'} aria-invalid={status === 'error'}
/> />
{status === 'done' && ( {status === 'done' && (
<p className="mt-2 flex items-center gap-1.5 text-xs font-light text-char" role="status"> <p className="mt-2 flex items-center gap-1.5 text-xs font-light text-char" role="status">
<CheckIcon className="h-3.5 w-3.5" /> <CheckIcon className="h-3.5 w-3.5" />
{newsletter.success_text} عضویت شما ثبت شد.
</p> </p>
)} )}
</div> </div>
<Button type="submit" variant="dark-ivory" disabled={sending}> <Button type="submit" variant="dark-ivory" disabled={sending}>
<SendIcon className="h-4 w-4" /> <SendIcon className="h-4 w-4" />
{sending ? newsletter.sending_label : ( {sending ? 'در حال ارسال…' : 'عضویت'}
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)}
</Button> </Button>
</form> </form>
</Reveal> </Reveal>
+5 -19
View File
@@ -1,40 +1,28 @@
import React from 'react' import React from 'react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { ServiceIcon } from '../ServiceIcon' import { ServiceIcon } from '../ServiceIcon'
import { useSectionHeading, useSite } from '../../api' import { useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Services() { export function Services() {
const { services } = useSite() 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 ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right"> <Reveal className="max-w-2xl text-right">
<h2 id="services-title" className="text-section font-bold text-ivory"> <h2 id="services-title" className="text-section font-bold text-ivory">
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} /> خدمات
</h2> </h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60"> <p className="mt-5 text-base font-light leading-9 text-ivory/60">
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} /> از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام
کنترل کیفیت.
</p> </p>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal> </Reveal>
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4"> <div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{services.map((service, i) => ( {services.map((service, i) => (
<Reveal key={service.id} delay={0.04 * i} className="h-full"> <Reveal key={service.title} 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"> <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 <ServiceIcon
name={service.icon} name={service.icon}
@@ -45,10 +33,8 @@ export function Services() {
</h3> </h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p> <p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
</article> </article>
</EditableItem>
</Reveal> </Reveal>
))} ))}
<AddItemButton resource="services" />
</div> </div>
</div> </div>
</section> </section>
+3 -26
View File
@@ -1,9 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { useSite } from '../../api'; import { useSite } from '../../api';
import { useFieldSaver } from '../../admin/AdminContext';
import { AddItemButton, EditableItem } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit';
function toFa(n: number) { function toFa(n: number) {
return n.toLocaleString('fa-IR'); return n.toLocaleString('fa-IR');
@@ -32,38 +29,18 @@ function Counter({ value }: {value: number;}) {
export function Stats() { export function Stats() {
const { stats } = useSite(); const { stats } = useSite();
const save = useFieldSaver();
return ( return (
<section className="bg-char py-16" aria-label="آمار و ارقام"> <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"> <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) => {stats.map((stat) =>
<EditableItem key={stat.id} resource="stats" item={stat}> <div key={stat.label} className="text-center">
<div className="text-center">
<p className="text-4xl font-bold text-copper md:text-5xl"> <p className="text-4xl font-bold text-copper md:text-5xl">
<Counter value={stat.value} /> <Counter value={stat.value} />
<InlineEdit {stat.suffix && <span className="mr-1 text-2xl">{stat.suffix}</span>}
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> </p>
<p className="mt-3 text-sm font-light text-ivory/55">{stat.label}</p>
</div> </div>
</EditableItem>
)} )}
<AddItemButton resource="stats" />
</div> </div>
</section>); </section>);
+25 -55
View File
@@ -2,34 +2,23 @@ import React from 'react'
import { ArrowLeftIcon } from 'lucide-react' import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { IMAGES } from '../../data/site'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' const BENTO = [
import { SingletonPill } from '../../admin/Editable' { src: IMAGES[0], alt: 'نمای برج مسکونی آرامش در تهران' },
{ src: IMAGES[1], alt: 'آتریوم مرکزی مجتمع تجاری پارسه' },
{ src: IMAGES[2], alt: 'ویلاهای ساخته‌شده در سواحل شمال' },
{ src: IMAGES[3], alt: 'نمای شبانه برج دوقلوی نیاوران' },
]
type StoryProps = { type StoryProps = {
showLink?: boolean; showLink?: boolean;
showLabel?: boolean; showLabel?: boolean;
showQuote?: boolean;
alignTop?: boolean; alignTop?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
} }
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) { export function Story({ showLink = true, showLabel = true, 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 ( return (
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title"> <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 className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
@@ -37,58 +26,40 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLabel && ( {showLabel && (
<Reveal> <Reveal>
<h2 id="story-title" className="text-section font-bold text-ivory"> <h2 id="story-title" className="text-section font-bold text-ivory">
<InlineEdit value={story.title} onSave={(v) => saveStory('title', v)} /> درباره ی ما
</h2> </h2>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal> </Reveal>
)} )}
<Reveal delay={0.08}> <Reveal delay={0.08}>
{story.paragraphs.map((paragraph, i) => ( <p className="mt-8 text-base font-light leading-9 text-ivory/65">
<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>
<p className="mt-5 text-base font-light leading-9 text-ivory/65">
هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز می‌شود؛ سپس تیم طراحی، سازه و اجرا در یک
فرآیند واحد آن را تا تحویل کلید پیش می‌برند.
</p> </p>
))}
</Reveal> </Reveal>
<Reveal delay={0.16}> <Reveal delay={0.16}>
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8"> <div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
<div> <div>
<p className="text-2xl font-bold text-copper"> <p className="text-2xl font-bold text-copper">۹۴٪</p>
<InlineEdit value={story.stat1_value} onSave={(v) => saveStory('stat1_value', v)} /> <p className="mt-2 text-sm font-light text-ivory/55">تحویل به‌موقع پروژه‌ها</p>
</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>
<div> <div>
<p className="text-2xl font-bold text-copper"> <p className="text-2xl font-bold text-copper">۵ سال</p>
<InlineEdit value={story.stat2_value} onSave={(v) => saveStory('stat2_value', v)} /> <p className="mt-2 text-sm font-light text-ivory/55">پشتیبانی پس از تحویل</p>
</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>
</div> </div>
</Reveal> </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 && ( {showLink && (
<Reveal delay={0.22}> <Reveal delay={0.22}>
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light"> <ButtonLink to="/about" variant="outline" className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} /> داستان کامل ما
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
</ButtonLink> </ButtonLink>
</Reveal> </Reveal>
@@ -101,9 +72,8 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
)} )}
</div> </div>
<div className="relative grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 right-0" /> {BENTO.map((item, i) => (
{story.bento.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}> <Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden"> <div className="overflow-hidden">
<img <img
+1 -6
View File
@@ -2,7 +2,6 @@ import React from 'react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Team() { export function Team() {
const { team } = useSite() const { team } = useSite()
@@ -12,14 +11,12 @@ export function Team() {
<SectionHeading <SectionHeading
title="تیم ما" title="تیم ما"
lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند." lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."
headingKey="team"
align="center" align="center"
/> />
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4"> <div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{team.map((member, i) => ( {team.map((member, i) => (
<Reveal key={member.id} delay={0.06 * i} className="h-full"> <Reveal key={member.name} 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"> <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"> <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)} {member.name.trim().charAt(0)}
@@ -30,10 +27,8 @@ export function Team() {
{member.bio} {member.bio}
</p> </p>
</article> </article>
</EditableItem>
</Reveal> </Reveal>
))} ))}
<AddItemButton resource="team" />
</div> </div>
</div> </div>
</section> </section>
+7 -11
View File
@@ -3,13 +3,11 @@ import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react'; import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
import { SectionHeading } from '../SectionHeading'; import { SectionHeading } from '../SectionHeading';
import { useSite } from '../../api'; import { useSite } from '../../api';
import { EditableItem } from '../../admin/Editable';
export function Testimonials() { export function Testimonials() {
const { testimonials } = useSite(); const { testimonials } = useSite();
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))]; const item = testimonials[index];
if (!current) return null;
const go = (dir: number) => { const go = (dir: number) => {
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length); setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
@@ -18,25 +16,23 @@ export function Testimonials() {
return ( return (
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading headingKey="testimonials" align="center" /> <SectionHeading title="نظرات مشتریان" align="center" />
<div className="relative mx-auto mt-14 max-w-3xl text-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" /> <QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.blockquote <motion.blockquote
key={current.id} key={item.name}
initial={{ opacity: 0, y: 16 }} initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }} exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}> 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">«{item.quote}»</p>
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
<footer className="mt-8"> <footer className="mt-8">
<p className="text-base font-bold text-copper">{current.name}</p> <p className="text-base font-bold text-copper">{item.name}</p>
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p> <p className="mt-1 text-sm font-light text-ivory/45">{item.role}</p>
</footer> </footer>
</EditableItem>
</motion.blockquote> </motion.blockquote>
</AnimatePresence> </AnimatePresence>
@@ -52,7 +48,7 @@ export function Testimonials() {
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر"> <div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
{testimonials.map((t, i) => {testimonials.map((t, i) =>
<button <button
key={t.id} key={t.name}
type="button" type="button"
role="tab" role="tab"
aria-selected={i === index} aria-selected={i === index}
+1 -8
View File
@@ -2,7 +2,6 @@ import React, { useRef } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion' import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSite, type TimelineItem } from '../../api' import { useSite, type TimelineItem } from '../../api'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { AddItemButton, EditableItem } from '../../admin/Editable'
const ease = [0.23, 1, 0.32, 1] as const const ease = [0.23, 1, 0.32, 1] as const
@@ -32,13 +31,11 @@ function Item({ item, index }: { item: Milestone; index: number }) {
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }} animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.55, ease }} transition={{ duration: 0.55, ease }}
> >
<EditableItem resource="timeline" item={item}>
<div className="border border-ivory/10 bg-ink p-6 md:p-7"> <div className="border border-ivory/10 bg-ink p-6 md:p-7">
<span className="text-sm font-light text-copper">{item.year}</span> <span className="text-sm font-light text-copper">{item.year}</span>
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3> <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> <p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
</div> </div>
</EditableItem>
</motion.div> </motion.div>
</li> </li>
) )
@@ -61,7 +58,6 @@ export function Timeline() {
<SectionHeading <SectionHeading
title="مسیر ما" title="مسیر ما"
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید." lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."
headingKey="timeline"
align="center" align="center"
/> />
</div> </div>
@@ -79,12 +75,9 @@ export function Timeline() {
<ol className="flex flex-col gap-12 md:gap-16"> <ol className="flex flex-col gap-12 md:gap-16">
{timeline.map((item, i) => ( {timeline.map((item, i) => (
<Item key={item.id} item={item} index={i} /> <Item key={item.year} item={item} index={i} />
))} ))}
</ol> </ol>
<div className="mt-12">
<AddItemButton resource="timeline" />
</div>
</div> </div>
</div> </div>
</section> </section>
+9 -39
View File
@@ -1,70 +1,40 @@
import React from 'react'; import React from 'react';
import { Reveal } from '../Reveal'; import { Reveal } from '../Reveal';
import { SectionHeading } from '../SectionHeading'; import { SectionHeading } from '../SectionHeading';
import { useSectionHeading, useSite } from '../../api'; import { IMAGES } from '../../data/site';
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable'; import { useSite } from '../../api';
import { InlineEdit } from '../admin/InlineEdit';
import { useFieldSaver } from '../../admin/AdminContext';
export function WhyUs() { export function WhyUs() {
const { why_us } = useSite(); 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 ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title"> <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="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
<div className="lg:col-span-5"> <div className="lg:col-span-5">
<SectionHeading <SectionHeading
title={heading.title} title="چرا گروه ساختمانی آرامش"
lead={heading.lead} lead="شش دلیلی که کارفرمایان و سرمایه‌گذاران بار دوم هم به ما برمی‌گردند." />
headingKey="why_us" />
{heading.image && (
<Reveal delay={0.12}> <Reveal delay={0.12}>
<img <img
src={heading.image} src={IMAGES[3]}
alt={heading.title} alt="نمای شبانه برج دوقلوی نیاوران"
className="mt-10 h-80 w-full object-cover" /> className="mt-10 h-80 w-full object-cover" />
</Reveal> </Reveal>
)}
</div> </div>
<div className="lg:col-span-7"> <div className="lg:col-span-7">
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2"> <div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
{why_us.map((item, i) => {why_us.map((item, i) =>
<Reveal key={item.id} delay={0.05 * i}> <Reveal key={item.title} delay={0.05 * i}>
<EditableItem resource="why_us" item={item}>
<div className="flex h-full flex-col border-t border-ivory/10 py-8"> <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> <span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
<h3 className="mt-4 text-lg font-bold text-ivory"> <h3 className="mt-4 text-lg font-bold text-ivory">{item.title}</h3>
<InlineEdit <p className="mt-3 text-sm font-light leading-8 text-ivory/55">{item.desc}</p>
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> </div>
</EditableItem>
</Reveal> </Reveal>
)} )}
</div> </div>
<div className="mt-8">
<AddItemButton resource="why_us" />
</div>
</div> </div>
</div> </div>
</section>); </section>);
+3 -3
View File
@@ -1,3 +1,3 @@
// آدرس بک‌اند — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ (/api از طریق Traefik). // آدرس بک‌اند FastAPI — در محیط تولید مقدار VITE_API_URL را در فایل .env تنظیم کنید.
// فقط اگر بک‌اند روی مبدأ دیگری سرو می‌شود VITE_API_URL را تنظیم کنید. export const API_URL: string =
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? '' (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8000'
+118 -9
View File
@@ -1,16 +1,60 @@
// انواع مشترک داده‌ها — همه محتوای متنی سایت از بک‌اند (GET /api/site) بارگذاری می‌شود. export const BRAND = {
name: 'گروه ساختمانی آرامش',
logo: '/images/logo.png',
hero: '/images/hero.png',
contactBg: '/images/contact.png',
phone: '۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰',
phoneHref: 'tel:+982188742210',
email: 'info@arameshbuildinggroup.com',
address: 'تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم'
};
export const IMAGES = [
'/images/img1.png',
'/images/img2.png',
'/images/contact.png',
'/images/img4.png',
'/images/img5.png'
];
export const NAV = [
{ label: 'صفحه اصلی', to: '/' },
{ label: 'درباره ما', to: '/about' },
{ label: 'خدمات', to: '/services' },
{ label: 'پروژه‌ها', to: '/projects' },
{ label: 'سرمایه‌گذاری', to: '/investment' },
{ label: 'وبلاگ', to: '/blog' },
{ label: 'سوالات متداول', to: '/faq' },
{ label: 'تماس با ما', to: '/contact' }
];
export type Service = { export type Service = {
id: number;
title: string; title: string;
desc: string; desc: string;
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape'; icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
}; };
export const SERVICES: Service[] = [
{ 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' }
];
export const STATS = [
{ value: 21, suffix: '+', label: 'سال تجربه ساخت' },
{ value: 47, suffix: '', label: 'پروژه تحویل‌شده' },
{ value: 860, suffix: 'هزار', label: 'مترمربع زیربنا' },
{ value: 1200, suffix: '+', label: 'خانواده ساکن' }
];
export type ProjectStatus = 'done' | 'progress'; export type ProjectStatus = 'done' | 'progress';
export type Project = { export type Project = {
id: number;
slug: string; slug: string;
title: string; title: string;
location: string; location: string;
@@ -26,12 +70,77 @@ export type Project = {
body: string[]; body: string[];
}; };
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */ export const STATUS_LABEL: Record<ProjectStatus, string> = {
export type ArticleBodyDoc = { type: 'doc'; content: unknown[] }; done: 'انجام شده',
progress: 'در حال انجام'
};
export const PROJECTS: Project[] = [
{ 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: ['برنامه‌ریزی فضایی این مجموعه بر اساس دفاتر انعطاف‌پذیر انجام شد؛ هر تراز می‌تواند به دو، سه یا شش دفتر مستقل تقسیم شود.', 'اجرای تأسیسات مکانیکی به‌صورت هم‌زمان با سفت‌کاری پیش می‌رود تا زمان کل پروژه کوتاه‌تر شود.'] }
];
export const BEFORE_AFTER = {
title: 'از کانسپت تا خلق واقعیت',
lead: 'دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل می‌شود.',
before: '/images/before.webp',
after: '/images/after.webp',
beforeLabel: 'کانسپت اولیه',
afterLabel: 'پروژه تکمیل‌شده',
};
export const 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] }
];
export const WHY = [
{ title: 'کیفیت بدون مصالحه', desc: 'انتخاب مصالح و مجریان بر اساس ماندگاری چنددهه‌ای، نه سریع‌ترین تحویل.' },
{ title: 'شفافیت مالی کامل', desc: 'گزارش ماهانه هزینه‌ها و پیشرفت فیزیکی برای همه شرکای پروژه.' },
{ title: 'تحویل به‌موقع', desc: '۹۴٪ پروژه‌های ما در بازه زمانی قراردادی یا زودتر تحویل داده شده‌اند.' },
{ title: 'طراحی اختصاصی', desc: 'هیچ نقشه‌ای دو بار اجرا نمی‌شود؛ هر پروژه هویت معماری خودش را دارد.' },
{ title: 'خدمات پس از تحویل', desc: 'پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.' },
{ title: 'پایداری و انرژی', desc: 'طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.' }
];
export const TESTIMONIALS = [
{ name: 'مهندس سارا کیانی', role: 'سرمایه‌گذار پروژه پارسه', quote: 'گزارش‌های ماهانه گروه ساختمانی آرامش آن‌قدر دقیق بود که هیچ‌وقت نیاز نشد نگران سرمایه‌ام باشم. پروژه دو ماه زودتر از موعد به بهره‌برداری رسید.' },
{ name: 'دکتر امیرحسین راد', role: 'ساکن برج الهیه', quote: 'کیفیت اجرا در جزئیاتی دیده می‌شود که معمولاً کسی به آن‌ها فکر نمی‌کند؛ از عایق صوتی پنجره‌ها تا نور غیرمستقیم راهروها.' },
{ name: 'مریم دلاور', role: 'مدیر مجموعه ویلاهای دریاکنار', quote: 'همکاری با تیم طراحی، تجربه‌ای واقعاً حرفه‌ای بود. ایده‌های ما را شنیدند و به زبان معماری ترجمه کردند.' }
];
export const TEAM = [
{ name: 'کامران آرامش', role: 'بنیان‌گذار و مدیرعامل', bio: 'بیش از سه دهه تجربه در ساخت بلندمرتبه.' },
{ name: 'نگار شریفی', role: 'مدیر ارشد طراحی', bio: 'معمار ارشد، فارغ‌التحصیل دانشگاه تهران و میلان.' },
{ name: 'بهزاد مرادی', role: 'مدیر اجرایی پروژه‌ها', bio: 'مسئول کنترل کیفیت و زمان‌بندی همه کارگاه‌ها.' },
{ name: 'الهام نیک‌آیین', role: 'مدیر سرمایه‌گذاری', bio: 'طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.' }
];
export type Article = { export type Article = {
id: number; slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; body: string[];
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
/** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */
body: ArticleBodyDoc | string[];
}; };
export const ARTICLES: Article[] = [
{ 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: ['نخست وضعیت سازه ارزیابی می‌شود؛ اگر مقاوم‌سازی هزینه‌ای بیش از ۴۰ درصد ساخت نو دارد، نوسازی منطقی‌تر است.', 'دومین معیار، ضوابط شهری روز است. گاهی تخریب، سطح زیربنای مجاز کمتری به شما می‌دهد و این تصمیم را کاملاً تغییر می‌دهد.', 'در بازسازی، بیشترین ارزش افزوده از سه جا می‌آید: نما، ورودی و بازآرایی پلان داخلی.'] }
];
export const FAQS = [
{ q: 'حداقل سرمایه برای مشارکت در پروژه‌های گروه ساختمانی آرامش چقدر است؟', a: 'ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایه‌گذاری از سطح مشخصی آغاز می‌شوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه می‌شود.' },
{ q: 'مدت زمان معمول ساخت یک برج مسکونی چقدر است؟', a: 'بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.' },
{ q: 'آیا امکان تغییر نقشه داخلی واحد وجود دارد؟', a: 'بله. تا پیش از پایان مرحله سفت‌کاری، امکان شخصی‌سازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.' },
{ q: 'خدمات پس از تحویل شامل چه مواردی می‌شود؟', a: 'پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دوره‌ای سالانه و تضمین اجرای نما و عایق‌کاری.' },
{ q: 'پروژه‌ها در چه شهرهایی اجرا می‌شوند؟', a: 'در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژه‌ای نیز در جزیره کیش در دست ساخت است.' }
];
+1 -117
View File
@@ -1,6 +1,3 @@
/* @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 */ /* CRITICAL: THE FOLLOWING TAILWIND IMPORTS MUST NEVER BE DELETED OR REORDERED — DO NOT DELETE THIS COMMENT */
@import 'tailwindcss/base'; @import 'tailwindcss/base';
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@@ -8,27 +5,13 @@
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */ /* 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 { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
body { body {
background-color: #27221c; background-color: #27221c;
color: #f5f3ef; color: #f5f3ef;
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif; font-family: Tahoma, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
::selection { ::selection {
@@ -40,102 +23,3 @@ body {
scroll-behavior: auto; 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;
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "magic-patterns-project",
"private": true,
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.30.2",
"lucide-react": "0.577.0",
"framer-motion": "11.18.2",
"@radix-ui/react-icons": "1.3.2",
"date-fns": "4.1.0",
"tailwind-merge": "2.6.1"
}
}
+14 -2
View File
@@ -6,13 +6,25 @@ import { Timeline } from '../components/home/Timeline'
import { WhyUs } from '../components/home/WhyUs' import { WhyUs } from '../components/home/WhyUs'
import { Team } from '../components/home/Team' import { Team } from '../components/home/Team'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { IMAGES } from '../data/site'
export function About() { export function About() {
return ( return (
<> <>
<PageHero pageKey="about" /> <PageHero
title="درباره ما"
lead="گروه ساختمانی آرامش مجموعه‌ای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساخت‌وساز لوکس فعالیت می‌کند."
image={IMAGES[3]}
/>
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} /> <Story showLink={false} showLabel={false} alignTop={true}>
<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">
«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی می‌سازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»
</p>
<p className="mt-5 text-sm font-bold text-copper">کامران آرامش — مدیرعامل شرکت ساختمانی آرامش</p>
</div>
</Story>
<Stats /> <Stats />
<Timeline /> <Timeline />
+2 -31
View File
@@ -1,31 +1,11 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react'; import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button'; import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { Newsletter } from '../components/home/Newsletter'; import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api'; import { useSite } from '../api';
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() { export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>(); const { slug } = useParams<{slug: string;}>();
@@ -45,7 +25,6 @@ export function ArticleDetail() {
} }
const { html, legacy } = renderBody(article);
const related = articles.filter((a) => a.slug !== article.slug).slice(0, 3); const related = articles.filter((a) => a.slug !== article.slug).slice(0, 3);
return ( return (
@@ -88,18 +67,10 @@ export function ArticleDetail() {
{article.excerpt} {article.excerpt}
</p> </p>
</Reveal> </Reveal>
{html ? ( {article.body.map((paragraph, i) =>
<Reveal delay={0.05}> <Reveal key={paragraph} delay={0.03 * i}>
<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> <p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
</Reveal> </Reveal>
)
)} )}
</div> </div>
</article> </article>
+7 -8
View File
@@ -5,8 +5,8 @@ import { PageHero } from '../components/PageHero'
import { Reveal } from '../components/Reveal' import { Reveal } from '../components/Reveal'
import { ArticleCard } from '../components/ArticleCard' import { ArticleCard } from '../components/ArticleCard'
import { Newsletter } from '../components/home/Newsletter' import { Newsletter } from '../components/home/Newsletter'
import { IMAGES } from '../data/site'
import { useSite } from '../api' import { useSite } from '../api'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function Blog() { export function Blog() {
const { articles } = useSite() const { articles } = useSite()
@@ -14,12 +14,15 @@ export function Blog() {
return ( return (
<> <>
<PageHero pageKey="blog" /> <PageHero
title="وبلاگ"
lead="یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار می‌گذرد."
image={IMAGES[2]}
/>
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات"> <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="mx-auto max-w-site px-6 lg:px-10">
<Reveal> <Reveal>
<EditableItem resource="articles" item={featured}>
<Link <Link
to={`/blog/${featured.slug}`} 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" 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"
@@ -53,18 +56,14 @@ export function Blog() {
</div> </div>
</div> </div>
</Link> </Link>
</EditableItem>
</Reveal> </Reveal>
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{rest.map((article, i) => ( {rest.map((article, i) => (
<Reveal key={article.id} delay={0.05 * i} className="h-full"> <Reveal key={article.slug} delay={0.05 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full">
<ArticleCard article={article} /> <ArticleCard article={article} />
</EditableItem>
</Reveal> </Reveal>
))} ))}
<AddItemButton resource="articles" />
</div> </div>
</div> </div>
</section> </section>
+15 -10
View File
@@ -2,21 +2,28 @@ import React from 'react'
import { PageHero } from '../components/PageHero' import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal' import { Reveal } from '../components/Reveal'
import { useSite } from '../api' import { IMAGES } from '../data/site'
import { AddItemButton, EditableItem } from '../admin/Editable'
const OFFICES = [
{ city: 'تهران', address: 'خیابان ولیعصر، برج آرامش، طبقه هجدهم', hours: 'شنبه تا چهارشنبه، ۹ تا ۱۸' },
{ city: 'اصفهان', address: 'خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم', hours: 'شنبه تا چهارشنبه، ۹ تا ۱۷' },
{ city: 'نوشهر', address: 'بلوار دریا، دفتر پروژه دریاکنار', hours: 'شنبه تا پنجشنبه، ۱۰ تا ۱۶' },
]
export function ContactPage() { export function ContactPage() {
const { offices } = useSite()
return ( return (
<> <>
<PageHero pageKey="contact" /> <PageHero
title="تماس با ما"
lead="برای مشاوره ساخت، سرمایه‌گذاری یا بازدید از پروژه‌ها، تیم ما در سه شهر آماده پاسخ‌گویی است."
image={IMAGES[1]}
/>
<section className="bg-ink py-24" aria-label="دفاتر ما"> <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"> <div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{offices.map((office, i) => ( {OFFICES.map((office, i) => (
<Reveal key={office.id} delay={0.06 * i} className="h-full"> <Reveal key={office.city} 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"> <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> <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> <p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
@@ -26,10 +33,8 @@ export function ContactPage() {
</p> </p>
</div> </div>
</article> </article>
</EditableItem>
</Reveal> </Reveal>
))} ))}
<AddItemButton resource="offices" />
</div> </div>
</section> </section>
+6 -1
View File
@@ -2,11 +2,16 @@ import React from 'react'
import { PageHero } from '../components/PageHero' import { PageHero } from '../components/PageHero'
import { Faq } from '../components/home/Faq' import { Faq } from '../components/home/Faq'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { IMAGES } from '../data/site'
export function FaqPage() { export function FaqPage() {
return ( return (
<> <>
<PageHero pageKey="faq" /> <PageHero
title="سوالات متداول"
lead="پیش از تماس، شاید پاسخ پرسش شما همین‌جا باشد. اگر نبود، خوشحال می‌شویم مستقیم صحبت کنیم."
image={IMAGES[1]}
/>
<Faq hideHeading={true} /> <Faq hideHeading={true} />
+30 -12
View File
@@ -4,24 +4,44 @@ import { Reveal } from '../components/Reveal'
import { SectionHeading } from '../components/SectionHeading' import { SectionHeading } from '../components/SectionHeading'
import { Stats } from '../components/home/Stats' import { Stats } from '../components/home/Stats'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { useSectionHeading, useSite } from '../api' import { IMAGES } from '../data/site'
import { AddItemButton, EditableItem } from '../admin/Editable'
const MODELS = [
{
title: 'مشارکت در ساخت',
desc: 'مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک می‌شوند؛ اجرا، مجوزها و فروش بر عهده ماست.',
points: ['ارزیابی رایگان زمین', 'قرارداد شفاف با ضمانت اجرا', 'گزارش ماهانه پیشرفت'],
},
{
title: 'صندوق پروژه‌محور',
desc: 'سرمایه‌گذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبه‌شده در پایان دوره.',
points: ['حداقل دوره ۲۴ ماه', 'گزارش ارزش‌گذاری فصلی', 'امکان انتقال واحد سرمایه'],
},
{
title: 'پیش‌خرید واحد',
desc: 'خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحله‌ای.',
points: ['پرداخت مرحله‌ای متناسب با پیشرفت', 'امکان شخصی‌سازی پلان', 'تضمین زمان تحویل'],
},
]
export function Investment() { export function Investment() {
const { investment_models } = useSite()
const heading = useSectionHeading('investment_models')
return ( return (
<> <>
<PageHero pageKey="investment" /> <PageHero
title="سرمایه‌گذاری"
lead="سه مسیر روشن برای ورود به بازار ساخت‌وساز لوکس، با ساختار مالی شفاف و گزارش‌دهی منظم."
image={IMAGES[4]}
/>
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} /> <SectionHeading
title="مدل‌های همکاری"
lead="بسته به میزان سرمایه، افق زمانی و سطح ریسکی که می‌پذیرید، یکی از این سه مسیر مناسب شماست."
/>
<div className="mt-14 grid gap-6 lg:grid-cols-3"> <div className="mt-14 grid gap-6 lg:grid-cols-3">
{investment_models.map((model, i) => ( {MODELS.map((model, i) => (
<Reveal key={model.id} delay={0.06 * i} className="h-full"> <Reveal key={model.title} 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"> <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> <p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
@@ -42,10 +62,8 @@ export function Investment() {
</ul> </ul>
</div> </div>
</article> </article>
</EditableItem>
</Reveal> </Reveal>
))} ))}
<AddItemButton resource="investment_models" />
</div> </div>
</div> </div>
</section> </section>
+3 -3
View File
@@ -6,13 +6,13 @@ import { ButtonLink } from '../components/Button'
import { ProjectCard } from '../components/ProjectCard' import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox' import { Lightbox } from '../components/Lightbox'
import { useSite, useStatusLabel } from '../api' import { STATUS_LABEL } from '../data/site'
import { useSite } from '../api'
export function ProjectDetail() { export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const { projects } = useSite() const { projects } = useSite()
const project = projects.find((p) => p.slug === slug) const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null) const [lightbox, setLightbox] = useState<number | null>(null)
if (!project) { if (!project) {
@@ -74,7 +74,7 @@ export function ProjectDetail() {
<Reveal className="order-1"> <Reveal className="order-1">
<span className="flex items-center gap-3 text-sm font-light text-copper"> <span className="flex items-center gap-3 text-sm font-light text-copper">
<span aria-hidden="true" className="h-px w-8 bg-copper" /> <span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel} {STATUS_LABEL[project.status]}
</span> </span>
<h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2> <h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2>
+14 -31
View File
@@ -4,36 +4,25 @@ import { Reveal } from '../components/Reveal';
import { ProjectCard } from '../components/ProjectCard'; import { ProjectCard } from '../components/ProjectCard';
import { Gallery } from '../components/home/Gallery'; import { Gallery } from '../components/home/Gallery';
import { ContactSection } from '../components/ContactSection'; import { ContactSection } from '../components/ContactSection';
import type { ProjectStatus } from '../data/site'; import { IMAGES, type ProjectStatus } from '../data/site';
import { useSite, useStatusLabel } from '../api'; import { useSite } from '../api';
import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit'; const FILTERS: {key: ProjectStatus;label: string;}[] = [
import { AddItemButton, EditableItem } from '../admin/Editable'; { key: 'done', label: 'انجام شده' },
{ key: 'progress', label: 'در حال انجام' }];
export function Projects() { export function Projects() {
const { projects, status_labels } = useSite(); const { projects } = 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 [status, setStatus] = useState<ProjectStatus>('done');
const list = projects.filter((p) => p.status === status); const list = projects.filter((p) => p.status === status);
return ( return (
<> <>
<PageHero pageKey="projects" /> <PageHero
title="پروژه‌ها"
lead="مجموعه‌ای از برج‌های مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژه‌های تحویل‌شده تا کارگاه‌های فعال امروز."
image={IMAGES[0]} />
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژه‌ها"> <section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژه‌ها">
@@ -59,10 +48,7 @@ export function Projects() {
} }
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char"> <span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
<InlineEdit {filter.label}
value={filter.label}
onSave={(v) => saveStatusLabel(filter.key, v)}
/>
</span> </span>
</button> </button>
)} )}
@@ -70,13 +56,10 @@ export function Projects() {
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{list.map((project, i) => {list.map((project, i) =>
<Reveal key={project.id} delay={0.05 * i} className="h-full"> <Reveal key={project.slug} delay={0.05 * i} className="h-full">
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} imageClass="h-80" /> <ProjectCard project={project} imageClass="h-80" />
</EditableItem>
</Reveal> </Reveal>
)} )}
<AddItemButton resource="projects" />
</div> </div>
</div> </div>
</section> </section>
+16 -12
View File
@@ -5,35 +5,39 @@ import { Faq } from '../components/home/Faq'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal' import { Reveal } from '../components/Reveal'
import { SectionHeading } from '../components/SectionHeading' import { SectionHeading } from '../components/SectionHeading'
import { useSectionHeading, useSite } from '../api' import { IMAGES } from '../data/site'
import { AddItemButton, EditableItem } from '../admin/Editable'
const PROCESS = [
{ step: '۰۱', title: 'مطالعه و امکان‌سنجی', desc: 'بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.' },
{ step: '۰۲', title: 'طراحی و فاز اجرایی', desc: 'کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشه‌های اجرایی.' },
{ step: '۰۳', title: 'اجرا و کنترل کیفیت', desc: 'ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.' },
{ step: '۰۴', title: 'تحویل و پشتیبانی', desc: 'تحویل کلید، آموزش بهره‌برداری و پنج سال پشتیبانی فنی.' },
]
export function ServicesPage() { export function ServicesPage() {
const { process_steps } = useSite()
const heading = useSectionHeading('process')
return ( return (
<> <>
<PageHero pageKey="services" /> <PageHero
title="خدمات"
lead="هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهره‌برداری پوشش می‌دهند."
image={IMAGES[1]}
/>
<Services /> <Services />
<section className="bg-char py-24 lg:py-32" aria-labelledby="process-title"> <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"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} align="center" /> <SectionHeading title="فرآیند کار ما" lead="چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید." align="center" />
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4"> <div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
{process_steps.map((item, i) => ( {PROCESS.map((item, i) => (
<Reveal key={item.id} delay={0.06 * i} className="h-full"> <Reveal key={item.step} 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"> <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> <p className="text-4xl font-bold text-copper">{item.step}</p>
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3> <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> <p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
</div> </div>
</EditableItem>
</Reveal> </Reveal>
))} ))}
<AddItemButton resource="process_steps" />
</div> </div>
</div> </div>
</section> </section>
+1 -1
View File
@@ -9,7 +9,7 @@ export default {
ivory: '#F5F3EF', ivory: '#F5F3EF',
}, },
fontFamily: { fontFamily: {
pinar: ['Pinar', 'Vazirmatn', 'Tahoma', 'sans-serif'], pinar: ['Tahoma', 'Segoe UI', 'sans-serif'],
}, },
transitionTimingFunction: { transitionTimingFunction: {
lux: 'cubic-bezier(0.23, 1, 0.32, 1)', lux: 'cubic-bezier(0.23, 1, 0.32, 1)',
-7
View File
@@ -4,11 +4,4 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
// در حالت توسعه، /api و /media به Traefik پروکسی می‌شوند (پورت ۵۱۷۳ روی هاست)
proxy: {
'/api': 'http://localhost:5173',
'/media': 'http://localhost:5173',
},
},
}) })