پروژه کامل: فرانت متصل به API + داکرایز

- اتصال کامل فرانت به API: همه محتوای سایت (پروژه‌ها، مقالات، خدمات، برند و...) از بک‌اند خوانده می‌شود (SiteProvider + useSite)
- فرم تماس و خبرنامه متصل به بک‌اند و تست‌شده در مرورگر
- ساختار مونوریپو: frontend/ + backend/
- Dockerfile برای هر دو بخش + docker-compose (PostgreSQL + FastAPI + nginx)
- nginx با پروکسی /api — بدون نیاز به CORS در حالت داکر
- README کامل فارسی
This commit is contained in:
Behrad
2026-08-29 13:26:58 +03:30
parent ed14435485
commit ad75f7a7bd
78 changed files with 8242 additions and 133 deletions
+5
View File
@@ -9,6 +9,11 @@ env/
# Environment # Environment
.env .env
# Node / Vite
node_modules/
dist/
.vite/
# IDE / OS # IDE / OS
.idea/ .idea/
.vscode/ .vscode/
+53 -133
View File
@@ -1,154 +1,74 @@
# بک‌اند آرامش پلازا (ArameshPlaza API) # آرامش پلازا — پروژه کامل (فرانت‌اند + بک‌اند)
بک‌اند سایت آرامش پلازا — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**. مونوریپوی سایت آرامش پلازا شامل:
## امکانات - **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده می‌شود)
- **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT
- **API عمومی محتوا**: برند، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، و بخش قبل/بعد ## ساختار
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیام‌های تماس و مشترکین
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد
- مستندات تعاملی Swagger در مسیر `/docs`
## راه‌اندازی ```
├── frontend/ # سایت React
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند)
│ ├── Dockerfile # بیلد Node + سرو با nginx
│ └── nginx.conf # پروکسی /api به بک‌اند
├── backend/ # بک‌اند FastAPI
│ ├── app/ # کد منبع
│ ├── requirements.txt
│ ├── Dockerfile
│ └── .env.example
└── docker-compose.yml # اجرای کامل با یک دستور
```
### ۱. پیش‌نیازها ## اجرا با Docker (ساده‌ترین راه)
- Python 3.11 یا جدیدتر
- PostgreSQL در حال اجرا (پورت پیش‌فرض 5432)
### ۲. نصب وابستگی‌ها
```bash ```bash
cd arameshplaza-backend docker compose up --build
```
- سایت: http://localhost:5173
- مستندات API: http://localhost:8000/docs
- دیتابیس PostgreSQL در کانتینر جدا (داده‌ها در volume ذخیره می‌شوند)
- بک‌اند هنگام شروع، خودش جداول و داده‌های اولیه سایت را seed می‌کند
- nginx درخواست‌های `/api` را خودش به بک‌اند پروکسی می‌کند — نیازی به تنظیم CORS نیست
## اجرای محلی (توسعه)
### بک‌اند
```bash
cd backend
python -m venv .venv python -m venv .venv
.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate
# ویندوز
.venv\Scripts\activate
# لینوکس / مک
source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
```
### ۳. تنظیمات # فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
python -m app.seed # ساخت جداول + محتوای اولیه
فایل `.env.example` را به نام `.env` کپی کنید و مقادیر را تنظیم کنید:
```bash
cp .env.example .env # لینوکس / مک
copy .env.example .env # ویندوز
```
مهم‌ترین تنظیم `DATABASE_URL` است:
```
DATABASE_URL=postgresql+psycopg://postgres:YOUR_PASSWORD@localhost:5432/arameshplaza_api
```
### ۴. ساخت دیتابیس و Seed
```bash
# ساخت دیتابیس (یک‌بار):
psql -U postgres -c "CREATE DATABASE arameshplaza_api;"
# ساخت جداول + وارد کردن محتوای سایت + ساخت ادمین پیش‌فرض:
python -m app.seed
# بازنشانی کامل داده‌ها در صورت نیاز:
python -m app.seed --force
```
### ۵. اجرا
```bash
uvicorn app.main:app --reload uvicorn app.main:app --reload
``` ```
سپس: ### فرانت‌اند
- مستندات API: http://localhost:8000/docs
- بررسی سلامت: http://localhost:8000/api/health
## ورود به پنل مدیریت ```bash
cd frontend
``` npm install
POST /api/auth/login npm run dev # http://localhost:5173
{ "username": "admin", "password": "aramesh123" }
``` ```
پاسخ شامل `access_token` است. توکن را در هدر همه درخواست‌های مدیریتی قرار دهید: آدرس بک‌اند در `frontend/src/config.ts` از `VITE_API_URL` خوانده می‌شود؛
اگر تنظیم نشود پیش‌فرض `http://localhost:8000` است.
``` ## امکانات بک‌اند
Authorization: Bearer <access_token>
```
> نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید. - **API عمومی**: برند، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم، تایم‌لاین، آمار و...
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...`
- همه محتوا در یک درخواست: `GET /api/site`
## فهرست انتهاپوینت‌ها > در محیط واقعی حتماً `SECRET_KEY` و `ADMIN_PASSWORD` را تغییر دهید.
### عمومی (بدون نیاز به توکن) ## نکات امنیتی
| متد | مسیر | توضیح | - فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است)
|---|---|---| - رمز عبور ادمین با bcrypt هش می‌شود
| GET | `/api/health` | وضعیت سرور | - توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
| GET | `/api/brand` | اطلاعات برند و تماس |
| GET | `/api/services` | خدمات |
| GET | `/api/projects` | همه پروژه‌ها |
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
| GET | `/api/articles` | همه مقالات |
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
| GET | `/api/testimonials` | نظرات مشتریان |
| GET | `/api/faqs` | سوالات متداول |
| GET | `/api/team` | اعضای تیم |
| GET | `/api/timeline` | تایم‌لاین شرکت |
| GET | `/api/stats` | آمار |
| GET | `/api/why-us` | چرا آرامش پلازا |
| GET | `/api/before-after` | بخش قبل/بعد |
| GET | `/api/site` | **همه محتوا در یک درخواست** |
| POST | `/api/contact` | ثبت پیام تماس |
| POST | `/api/newsletter` | عضویت در خبرنامه |
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
### مدیریت (نیاز به توکن Bearer)
برای هر یک از این مسیرها، CRUD کامل در دسترس است:
`brand`، `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `before-after`
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف |
| GET | `/api/admin/messages` | پیام‌های تماس (با `?unread_only=true` فقط خوانده‌نشده‌ها) |
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خوانده‌شدن |
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
## اتصال به فرانت‌اند
در پروژه React (پوشه `ArameshPlaza`)، فایل `src/config.ts` آدرس بک‌اند را نگه می‌دارد:
```ts
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
```
در محیط تولید مقدار `VITE_API_URL` را روی آدرس عمومی API تنظیم کنید و مطمئن شوید آدرس سایت در `CORS_ORIGINS` فایل `.env` بک‌اند قرار دارد.
## ساختار پروژه
```
app/
├── main.py # نقطه ورود FastAPI، CORS، ثبت روترها
├── config.py # تنظیمات از .env
├── database.py # اتصال PostgreSQL و نشست‌ها
├── models.py # مدل‌های SQLAlchemy
├── schemas.py # اسکیماهای Pydantic (ورودی/خروجی)
├── auth.py # هش رمز عبور و JWT
├── seed.py # اسکریپت seed
├── seed_data.py # داده‌های اولیه سایت
└── api/
├── public.py # انتهای عمومی محتوا
├── forms.py # فرم تماس و خبرنامه
└── admin.py # ورود و پنل مدیریت
```
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
# ساخت جداول و seed (بدون حذف داده‌های موجود) و سپس اجرای سرور
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+154
View File
@@ -0,0 +1,154 @@
# بک‌اند آرامش پلازا (ArameshPlaza API)
بک‌اند سایت آرامش پلازا — ساخته‌شده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
## امکانات
- **API عمومی محتوا**: برند، خدمات، پروژه‌ها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایم‌لاین، آمار، چرا آرامش، و بخش قبل/بعد
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیام‌های تماس و مشترکین
- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار می‌گیرد
- مستندات تعاملی Swagger در مسیر `/docs`
## راه‌اندازی
### ۱. پیش‌نیازها
- Python 3.11 یا جدیدتر
- PostgreSQL در حال اجرا (پورت پیش‌فرض 5432)
### ۲. نصب وابستگی‌ها
```bash
cd arameshplaza-backend
python -m venv .venv
# ویندوز
.venv\Scripts\activate
# لینوکس / مک
source .venv/bin/activate
pip install -r requirements.txt
```
### ۳. تنظیمات
فایل `.env.example` را به نام `.env` کپی کنید و مقادیر را تنظیم کنید:
```bash
cp .env.example .env # لینوکس / مک
copy .env.example .env # ویندوز
```
مهم‌ترین تنظیم `DATABASE_URL` است:
```
DATABASE_URL=postgresql+psycopg://postgres:YOUR_PASSWORD@localhost:5432/arameshplaza_api
```
### ۴. ساخت دیتابیس و Seed
```bash
# ساخت دیتابیس (یک‌بار):
psql -U postgres -c "CREATE DATABASE arameshplaza_api;"
# ساخت جداول + وارد کردن محتوای سایت + ساخت ادمین پیش‌فرض:
python -m app.seed
# بازنشانی کامل داده‌ها در صورت نیاز:
python -m app.seed --force
```
### ۵. اجرا
```bash
uvicorn app.main:app --reload
```
سپس:
- مستندات API: http://localhost:8000/docs
- بررسی سلامت: http://localhost:8000/api/health
## ورود به پنل مدیریت
```
POST /api/auth/login
{ "username": "admin", "password": "aramesh123" }
```
پاسخ شامل `access_token` است. توکن را در هدر همه درخواست‌های مدیریتی قرار دهید:
```
Authorization: Bearer <access_token>
```
> نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید.
## فهرست انتهاپوینت‌ها
### عمومی (بدون نیاز به توکن)
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/health` | وضعیت سرور |
| GET | `/api/brand` | اطلاعات برند و تماس |
| GET | `/api/services` | خدمات |
| GET | `/api/projects` | همه پروژه‌ها |
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
| GET | `/api/articles` | همه مقالات |
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
| GET | `/api/testimonials` | نظرات مشتریان |
| GET | `/api/faqs` | سوالات متداول |
| GET | `/api/team` | اعضای تیم |
| GET | `/api/timeline` | تایم‌لاین شرکت |
| GET | `/api/stats` | آمار |
| GET | `/api/why-us` | چرا آرامش پلازا |
| GET | `/api/before-after` | بخش قبل/بعد |
| GET | `/api/site` | **همه محتوا در یک درخواست** |
| POST | `/api/contact` | ثبت پیام تماس |
| POST | `/api/newsletter` | عضویت در خبرنامه |
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
### مدیریت (نیاز به توکن Bearer)
برای هر یک از این مسیرها، CRUD کامل در دسترس است:
`brand`، `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `before-after`
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف |
| GET | `/api/admin/messages` | پیام‌های تماس (با `?unread_only=true` فقط خوانده‌نشده‌ها) |
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خوانده‌شدن |
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
## اتصال به فرانت‌اند
در پروژه React (پوشه `ArameshPlaza`)، فایل `src/config.ts` آدرس بک‌اند را نگه می‌دارد:
```ts
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
```
در محیط تولید مقدار `VITE_API_URL` را روی آدرس عمومی API تنظیم کنید و مطمئن شوید آدرس سایت در `CORS_ORIGINS` فایل `.env` بک‌اند قرار دارد.
## ساختار پروژه
```
app/
├── main.py # نقطه ورود FastAPI، CORS، ثبت روترها
├── config.py # تنظیمات از .env
├── database.py # اتصال PostgreSQL و نشست‌ها
├── models.py # مدل‌های SQLAlchemy
├── schemas.py # اسکیماهای Pydantic (ورودی/خروجی)
├── auth.py # هش رمز عبور و JWT
├── seed.py # اسکریپت seed
├── seed_data.py # داده‌های اولیه سایت
└── api/
├── public.py # انتهای عمومی محتوا
├── forms.py # فرم تماس و خبرنامه
└── admin.py # ورود و پنل مدیریت
```
View File
View File
View File
+44
View File
@@ -0,0 +1,44 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: aramesh
POSTGRES_PASSWORD: aramesh
POSTGRES_DB: arameshplaza
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
interval: 5s
timeout: 5s
retries: 12
backend:
build: ./backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza
SECRET_KEY: change-me-to-a-long-random-string
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
ADMIN_USERNAME: admin
ADMIN_PASSWORD: aramesh123
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ""
restart: unless-stopped
depends_on:
- backend
ports:
- "5173:80"
volumes:
db_data:
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+23
View File
@@ -0,0 +1,23 @@
# ---------- مرحله ساخت ----------
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
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
+8
View File
@@ -0,0 +1,8 @@
# Magic Patterns - Vite Template
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu)
## Getting Started
1. Run `npm install`
2. Run `npm run dev`
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ArameshPlaza Luxury Website</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
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 ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
+4526
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "magic-patterns-vite-template",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "npx vite",
"build": "npx vite build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"preview": "npx vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
"@types/node": "^20.11.18",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "latest",
"eslint": "^8.50.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"postcss": "latest",
"tailwindcss": "3.4.17",
"typescript": "^5.5.4",
"vite": "^5.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+101
View File
@@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { Preloader } from './components/Preloader';
import { Header } from './components/Header';
import { Footer } from './components/Footer';
import { FloatingContact } from './components/FloatingContact';
import { Home } from './pages/Home';
import { About } from './pages/About';
import { ServicesPage } from './pages/ServicesPage';
import { Projects } from './pages/Projects';
import { ProjectDetail } from './pages/ProjectDetail';
import { Investment } from './pages/Investment';
import { Blog } from './pages/Blog';
import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage';
import { SiteProvider, useSiteData } from './api';
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'auto' });
}, [pathname]);
return null;
}
function AnimatedRoutes() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<motion.main
key={location.pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
<Routes location={location}>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:slug" element={<ProjectDetail />} />
<Route path="/investment" element={<Investment />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:slug" element={<ArticleDetail />} />
<Route path="/faq" element={<FaqPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="*" element={<Home />} />
</Routes>
</motion.main>
</AnimatePresence>);
}
function AppShell() {
const { data, error, reload } = useSiteData();
return (
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
<Preloader />
{error ? (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
</p>
<button
type="button"
onClick={reload}
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
>
تلاش دوباره
</button>
</div>
) : data ? (
<BrowserRouter>
<ScrollToTop />
<Header />
<AnimatedRoutes />
<Footer />
<FloatingContact />
</BrowserRouter>
) : null}
</div>
);
}
export function App() {
useEffect(() => {
document.documentElement.setAttribute('dir', 'rtl');
document.documentElement.setAttribute('lang', 'fa');
}, []);
return (
<SiteProvider>
<AppShell />
</SiteProvider>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site'
import { API_URL } from './config'
// انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند
export type BrandInfo = {
id: number
name: string
logo: string
hero: string
contact_bg: string
phone: string
phone_href: string
email: string
address: string
}
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
export type FaqItem = { id: number; question: string; answer: string }
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
export type StatItem = { id: number; value: number; suffix: string; label: string }
export type WhyUsItem = { id: number; title: string; desc: string }
export type BeforeAfterInfo = {
id: number
title: string
lead: string
before: string
after: string
before_label: string
after_label: string
}
export interface SiteData {
brand: BrandInfo
services: Service[]
projects: Project[]
articles: Article[]
testimonials: TestimonialItem[]
faqs: FaqItem[]
team: TeamMemberItem[]
timeline: TimelineItem[]
stats: StatItem[]
why_us: WhyUsItem[]
before_after: BeforeAfterInfo
}
interface SiteContextValue {
data: SiteData | null
error: boolean
reload: () => void
}
const SiteContext = createContext<SiteContextValue>({ data: null, error: false, reload: () => {} })
export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null)
const [error, setError] = useState(false)
const load = useCallback(() => {
let cancelled = false
setError(false)
fetch(`${API_URL}/api/site`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<SiteData>
})
.then((json) => {
if (!cancelled) {
if (json.brand && json.before_after) setData(json)
else setError(true)
}
})
.catch(() => {
if (!cancelled) setError(true)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => load(), [load])
const reload = useCallback(() => {
setData(null)
setError(false)
load()
}, [load])
return <SiteContext.Provider value={{ data, error, reload }}>{children}</SiteContext.Provider>
}
export function useSiteData(): SiteContextValue {
return useContext(SiteContext)
}
// فقط در کامپوننت‌هایی استفاده شود که پس از بارگذاری کامل داده‌ها رندر می‌شوند
export function useSite(): SiteData {
const { data } = useContext(SiteContext)
if (!data) {
throw new Error('useSite باید پس از بارگذاری داده‌های سایت استفاده شود')
}
return data
}
+48
View File
@@ -0,0 +1,48 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
export function ArticleCard({ article }: { article: Article }) {
return (
<Link
to={`/blog/${article.slug}`}
className="group flex h-full flex-col border border-ivory/10 bg-char transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`خواندن مقاله ${article.title}`}
>
<div className="overflow-hidden">
<img
src={article.image}
alt={`تصویر مقاله ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105 transform-gpu"
/>
</div>
<div className="flex flex-1 flex-col p-8">
<p className="text-xs font-light text-copper">{article.category}</p>
<h3 className="mt-4 text-lg font-bold leading-9 text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{article.title}
</h3>
<p className="mt-4 mb-8 text-sm font-light leading-[2.2rem] text-ivory/55">
{article.excerpt}
</p>
<div className="mt-auto flex items-center justify-between border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
</span>
</span>
<span className="flex items-center gap-2 text-copper">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 ease-out group-hover:-translate-x-1.5" />
</span>
</div>
</div>
</Link>
)
}
+93
View File
@@ -0,0 +1,93 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
const STYLES: Record<Variant, { base: string; fill: string; text: string }> = {
copper: {
base: 'bg-copper text-char',
fill: 'bg-char',
text: 'group-hover:text-ivory',
},
outline: {
base: 'border border-ivory/30 text-ivory',
fill: 'bg-copper',
text: 'group-hover:text-char',
},
dark: {
base: 'bg-char text-ivory',
fill: 'bg-copper',
text: 'group-hover:text-char',
},
'dark-ivory': {
base: 'bg-char text-ivory',
fill: 'bg-ivory',
text: 'group-hover:text-char',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full transition-transform duration-[600ms] ease-lux group-hover:translate-y-0 ${style.fill}`}
/>
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-[600ms] ease-lux ${style.text}`}
>
{children}
</span>
</>
)
}
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden px-8 py-4 text-sm font-bold focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
return (
<Link to={to} className={`${SHELL} ${STYLES[variant].base} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a href={href} className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+225
View File
@@ -0,0 +1,225 @@
import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from './Reveal'
import { Button } from './Button'
import { useSite } from '../api'
import { API_URL } from '../config'
export function ContactSection() {
const { brand } = useSite()
const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
title: title,
text: text,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-[8px] bg-ink/60',
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
},
showClass: {
popup: 'swal2-show',
backdrop: 'swal2-backdrop-show',
},
hideClass: {
popup: 'swal2-hide',
backdrop: 'swal2-backdrop-hide',
}
})
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (sending) return
const nameValue = form.name.trim()
const phoneValue = form.phone.trim()
if (nameValue.length < 3) {
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
return
}
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
if (!phoneValue || !phoneRegex.test(phoneValue)) {
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
return
}
setSending(true)
try {
const res = await fetch(`${API_URL}/api/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
})
if (!res.ok) {
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در ثبت درخواست', detail)
return
}
setSent(true)
} catch {
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally {
setSending(false)
}
}
return (
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
<img
src={brand.contact_bg}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="relative mx-auto max-w-site px-6 lg:px-10">
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
<Reveal>
<p className="text-sm font-light text-copper">تماس با ما</p>
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
پروژه‌تان را با ما در میان بگذارید
</h2>
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
شماره‌تان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس می‌گیرد.
</p>
</Reveal>
<Reveal delay={0.1}>
<ul className="mt-10 space-y-6">
<li className="flex items-start gap-4">
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">دفتر مرکزی</p>
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
</div>
</li>
<li className="flex items-start gap-4">
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">تلفن</p>
<a
href={brand.phone_href}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
{brand.phone}
</a>
</div>
</li>
<li className="flex items-start gap-4">
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div>
<p className="text-sm font-bold text-ivory">ایمیل</p>
<a
href={`mailto:${brand.email}`}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
{brand.email}
</a>
</div>
</li>
</ul>
</Reveal>
</div>
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
{sent ? (
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
<CheckCircle2Icon className="h-12 w-12 text-copper" />
<h3 className="mt-6 text-xl font-bold text-ivory">درخواست شما ثبت شد</h3>
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
از اعتماد شما سپاسگزاریم. کارشناسان ما به‌زودی با شما تماس می‌گیرند.
</p>
<Button
type="button"
variant="outline"
className="mt-8 font-light"
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '' })
}}
>
ثبت درخواست جدید
</Button>
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
نام
</label>
<input
id="contact-name"
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="نام و نام خانوادگی"
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
شماره تلفن <span className="text-copper">*</span>
</label>
<input
id="contact-phone"
type="tel"
dir="ltr"
required
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="09xx xxx xxxx"
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div className="md:col-span-2">
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
توضیحات <span className="font-light text-ivory/50">(اختیاری)</span>
</label>
<textarea
id="contact-note"
rows={6}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
placeholder="درباره پروژه یا درخواست خود بنویسید…"
className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
/>
</div>
<div className="md:col-span-2">
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
{sending ? 'در حال ارسال…' : 'ارسال درخواست مشاوره'}
</Button>
</div>
</form>
)}
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,17 @@
import React from 'react';
import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api';
export function FloatingContact() {
const { brand } = useSite();
return (
<a
href={brand.phone_href}
aria-label="تماس تلفنی با آرامش پلازا"
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-copper text-char shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
<span className="absolute inset-0 rounded-full border border-copper/50 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
<PhoneIcon className="h-5 w-5" />
</a>);
}
+94
View File
@@ -0,0 +1,94 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { NAV } from '../data/site';
import { useSite } from '../api';
export function Footer() {
const { brand, services } = useSite();
return (
<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="grid gap-12 lg:grid-cols-12">
<div className="lg:col-span-4">
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
آرامش پلازا از سال ۱۳۸۳ ساختمان‌هایی می‌سازد که قرار است چند نسل در آن‌ها زندگی کنند؛ معماری آرام،
اجرای دقیق و سرمایه‌گذاری شفاف.
</p>
<div className="mt-6 flex gap-3">
{[
{ Icon: InstagramIcon, label: 'اینستاگرام' },
{ Icon: LinkedinIcon, label: 'لینکدین' },
{ Icon: SendIcon, label: 'تلگرام' }].
map(({ Icon, label }) =>
<a
key={label}
href="#"
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">
<Icon className="h-4 w-4" />
</a>
)}
</div>
</div>
<div className="lg:col-span-2">
<h3 className="text-sm font-bold text-copper">صفحات</h3>
<ul className="mt-5 space-y-3">
{NAV.map((item) =>
<li key={item.to}>
<Link
to={item.to}
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
{item.label}
</Link>
</li>
)}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper">خدمات</h3>
<ul className="mt-5 space-y-3">
{services.map((service) =>
<li key={service.title} className="text-sm font-light text-ivory/60">
{service.title}
</li>
)}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper">ارتباط با ما</h3>
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
<li className="flex items-start gap-3">
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
<span className="leading-7">{brand.address}</span>
</li>
<li className="flex items-center gap-3">
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
{brand.phone}
</a>
</li>
<li className="flex items-center gap-3">
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
{brand.email}
</a>
</li>
</ul>
</div>
</div>
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
<p>© ۱۴۰۳ آرامش پلازا — تمامی حقوق محفوظ است.</p>
<p>طراحی و ساخت با دقتِ معماری</p>
</div>
</div>
</footer>);
}
+132
View File
@@ -0,0 +1,132 @@
import React, { useEffect, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
import { NAV } from "../data/site";
import { useSite } from "../api";
import { use } from "framer-motion/m";
export function Header() {
const { brand } = useSite();
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const { pathname } = useLocation();
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
});
useEffect(() => {
setOpen(false);
}, [pathname]);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-[background-color,border-color,backdrop-filter] duration-300 ease-lux ${
scrolled
? "border-b border-ivory/10 bg-ink/90 backdrop-blur-md"
: "border-b border-transparent bg-transparent"
}`}
>
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
<Link
to="/"
className="flex items-center gap-3"
aria-label={brand.name}
>
<img
src={brand.logo}
alt={brand.name}
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
scrolled ? "h-14" : "h-20"
}`}
/>
</Link>
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
<ul className="flex items-center gap-7">
{NAV.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
isActive ? "text-copper" : "text-ivory/80"
}`
}
>
{({ isActive }) => (
<>
{item.label}
<span
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
isActive
? "scale-x-100"
: "scale-x-0 group-hover:scale-x-100"
}`}
/>
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="flex items-center gap-3">
<a
href={brand.phone_href}
className="group relative hidden items-center gap-2 overflow-hidden border border-copper/50 px-5 py-2.5 text-sm font-medium text-copper [text-shadow:_0_1px_2px_rgba(0,0,0,0.5)] lg:flex"
>
<span
aria-hidden="true"
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0"
/>
<span className="relative z-10 flex items-center gap-2 transition-colors duration-300 ease-lux group-hover:text-char">
<PhoneIcon className="h-4 w-4" />
<span dir="ltr">{brand.phone}</span>
</span>
</a>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper xl:hidden"
aria-label={open ? "بستن منو" : "باز کردن منو"}
aria-expanded={open}
>
{open ? (
<XIcon className="h-5 w-5" />
) : (
<MenuIcon className="h-5 w-5" />
)}
</button>
</div>
</div>
{open && (
<nav
aria-label="ناوبری موبایل"
className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden"
>
<ul className="grid gap-1">
{NAV.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
isActive ? "text-copper" : "text-ivory/80"
}`
}
>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
)}
</header>
);
}
+100
View File
@@ -0,0 +1,100 @@
import React, { useCallback, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
type Props = {
images: string[]
index: number | null
onClose: () => void
onChange: (index: number) => void
}
export function Lightbox({ images, index, onClose, onChange }: Props) {
const isOpen = index !== null
const next = useCallback(() => {
if (index === null) return
onChange((index + 1) % images.length)
}, [index, images.length, onChange])
const prev = useCallback(() => {
if (index === null) return
onChange((index - 1 + images.length) % images.length)
}, [index, images.length, onChange])
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') prev()
if (e.key === 'ArrowLeft') next()
}
window.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
}, [isOpen, onClose, next, prev])
return (
<AnimatePresence>
{isOpen && (
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
>
<div
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
onClick={onClose}
aria-hidden="true"
/>
<button
type="button"
onClick={onClose}
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
>
<XIcon className="h-5 w-5" />
</button>
<motion.img
key={index}
src={images[index as number]}
alt=""
className="relative max-h-[80vh] w-auto max-w-full object-contain"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
<button
type="button"
onClick={prev}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
<span className="text-sm font-light text-ivory/70" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
<button
type="button"
onClick={next}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
+53
View File
@@ -0,0 +1,53 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeftIcon } from 'lucide-react'
type PageHeroProps = {
title: string
lead: string
image: string
}
export function PageHero({ title, lead, image }: PageHeroProps) {
return (
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
<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"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
صفحه اصلی
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{title}</span>
</nav>
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-[700px] text-hero font-bold text-ivory"
>
{title}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/65"
>
{lead}
</motion.p>
<div className="mt-8 h-px w-28 bg-copper" />
</div>
</section>
)
}
+73
View File
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { BRAND } from '../data/site'
export function Preloader() {
const [done, setDone] = useState(false)
const [progress, setProgress] = useState(0)
useEffect(() => {
const start = Date.now()
const total = 1800
const id = window.setInterval(() => {
const p = Math.min(100, ((Date.now() - start) / total) * 100)
setProgress(p)
if (p >= 100) {
window.clearInterval(id)
window.setTimeout(() => setDone(true), 180)
}
}, 25)
return () => window.clearInterval(id)
}, [])
useEffect(() => {
document.body.style.overflow = done ? '' : 'hidden'
return () => {
document.body.style.overflow = ''
}
}, [done])
return (
<AnimatePresence>
{!done && (
<motion.div
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-ink"
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
aria-live="polite"
aria-label="در حال بارگذاری"
>
<div className="relative h-36 w-36">
<img
src={BRAND.logo}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-contain opacity-[0.12]"
/>
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(${100 - progress}% 0 0 0)` }}>
<img src={BRAND.logo} alt="" aria-hidden="true" className="h-full w-full object-contain" />
</div>
<div
className="absolute inset-x-0 h-px bg-copper"
style={{ bottom: `${progress}%` }}
aria-hidden="true"
/>
</div>
<div className="mt-10 h-[2px] w-48 overflow-hidden bg-ivory/10">
<div className="h-full bg-copper" style={{ width: `${progress}%`, transition: 'width 120ms linear' }} />
</div>
<motion.p
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mt-4 text-sm font-light tracking-widest text-copper font-pinar"
>
{BRAND.name}
</motion.p>
</motion.div>
)}
</AnimatePresence>
)
}
+50
View File
@@ -0,0 +1,50 @@
import React from 'react'
import { Link } from 'react-router-dom'
export function ProjectCard({ project }: any) {
const isOngoing = project.status === 'progress'
const statusText = isOngoing ? 'در حال انجام' : 'انجام شده'
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
return (
<article className="group flex h-full flex-col overflow-hidden border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
<div className="relative aspect-[4/3] w-full overflow-hidden">
<img
src={project.image}
alt={project.title}
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/>
<div className={`absolute right-4 top-4 flex items-center gap-2 bg-ink/85 px-4 py-2 text-xs font-light backdrop-blur-md ${textColor}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />
{statusText}
</div>
</div>
<div className="flex flex-1 flex-col p-8">
<div className="text-center">
<h3 className="font-pinar text-xl font-bold text-ivory">
{project.title}
</h3>
<p className="mt-7 text-sm font-light text-ivory/60">
{project.location}
</p>
</div>
<div className="my-7 h-px w-full bg-ivory/10" />
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
<span>سال: {project.year}</span>
<span>{project.area} متر مربع</span>
</div>
</div>
</Link>
</article>
)
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { motion } from 'framer-motion';
type RevealProps = {
children: React.ReactNode;
delay?: number;
y?: number;
className?: string;
};
export function Reveal({ children, delay = 0, y = 28, className }: RevealProps) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.7, delay, ease: [0.23, 1, 0.32, 1] }}>
{children}
</motion.div>);
}
@@ -0,0 +1,29 @@
import React from 'react'
import { Reveal } from './Reveal'
type SectionHeadingProps = {
title: string
lead?: string
align?: 'right' | 'center'
tone?: 'copper' | 'ivory'
}
export function SectionHeading({
title,
lead,
align = 'right',
tone = 'ivory',
}: SectionHeadingProps) {
const isCenter = align === 'center'
return (
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
<h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
{title}
</h2>
{lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
</Reveal>
)
}
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import type { Service } from '../data/site';
type ServiceIconProps = {
name: Service['icon'];
className?: string;
};
const stroke = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const
};
export function ServiceIcon({ name, className = 'h-12 w-12' }: ServiceIconProps) {
return (
<svg viewBox="0 0 48 48" className={className} aria-hidden="true" role="presentation">
{name === 'draft' &&
<g {...stroke}>
<path d="M6 40h36" />
<path d="M10 40V14l14-8 14 8v26" />
<path d="M10 22h28M24 6v34" />
<circle cx="24" cy="22" r="3" />
</g>
}
{name === 'tower' &&
<g {...stroke}>
<path d="M8 42h32" />
<path d="M15 42V10l9-5 9 5v32" />
<path d="M19 15h10M19 21h10M19 27h10M19 33h10" />
</g>
}
{name === 'mall' &&
<g {...stroke}>
<path d="M6 42h36" />
<path d="M9 42V20h30v22" />
<path d="M9 20l6-10h18l6 10" />
<path d="M19 42V30h10v12" />
</g>
}
{name === 'villa' &&
<g {...stroke}>
<path d="M5 42h38" />
<path d="M9 42V22l15-10 15 10v20" />
<path d="M18 42V30h12v12" />
<path d="M9 26h30" />
</g>
}
{name === 'renew' &&
<g {...stroke}>
<path d="M10 42h28V20L24 10 10 20z" />
<path d="M31 6a9 9 0 1 0 6 15" />
<path d="M38 5v7h-7" />
</g>
}
{name === 'manage' &&
<g {...stroke}>
<rect x="8" y="8" width="32" height="32" rx="2" />
<path d="M8 17h32" />
<path d="M14 25h8M14 32h14M27 24l4 4 7-8" />
</g>
}
{name === 'invest' &&
<g {...stroke}>
<path d="M7 40h34" />
<path d="M12 40V26M22 40V18M32 40V30" />
<path d="M9 16l10-6 8 5 12-9" />
<path d="M39 6h-7M39 6v7" />
</g>
}
{name === 'landscape' &&
<g {...stroke}>
<path d="M5 40h38" />
<path d="M14 40V24M14 24c-4 0-7-3-7-6s3-5 7-5 7 2 7 5-3 6-7 6z" />
<path d="M31 40V22l7-7 4 5v20" />
<path d="M24 40c2-4 5-6 9-6" />
</g>
}
</svg>);
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard';
import { useSite } from '../../api';
export function Articles() {
const { articles } = useSite();
return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right">
<p className="text-sm font-light text-copper">وبلاگ</p>
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
آخرین مقالات
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری آرامش پلازا درباره آنچه در کارگاه و بازار می‌گذرد.
</p>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-3">
{articles.slice(0, 3).map((article, i) =>
<Reveal key={article.slug} delay={0.06 * i} className="h-full">
<ArticleCard article={article} />
</Reveal>
)}
</div>
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to="/blog" variant="outline" className="font-light">
همه مقالات
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</div>
</Reveal>
</div>
</section>);
}
@@ -0,0 +1,121 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { MoveHorizontalIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
export function BeforeAfter() {
const { before_after } = useSite()
const containerRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState(52)
const dragging = useRef(false)
const setFromClientX = useCallback((clientX: number) => {
const el = containerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const ratio = ((clientX - rect.left) / rect.width) * 100
setPosition(Math.min(98, Math.max(2, ratio)))
}, [])
useEffect(() => {
const move = (e: MouseEvent | TouchEvent) => {
if (!dragging.current) return
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
setFromClientX(clientX)
}
const stop = () => {
dragging.current = false
}
window.addEventListener('mousemove', move)
window.addEventListener('touchmove', move)
window.addEventListener('mouseup', stop)
window.addEventListener('touchend', stop)
return () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('touchmove', move)
window.removeEventListener('mouseup', stop)
window.removeEventListener('touchend', stop)
}
}, [setFromClientX])
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') setPosition((p) => Math.max(2, p - 4))
if (e.key === 'ArrowRight') setPosition((p) => Math.min(98, p + 4))
}
return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="ba-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title={before_after.title}
lead={before_after.lead}
align="center"
/>
<Reveal delay={0.1}>
<div
ref={containerRef}
className="relative mx-auto mt-14 aspect-[4/3] w-full max-w-4xl select-none overflow-hidden rounded-xl border border-ivory/10 shadow-2xl md:aspect-[16/10]"
onMouseDown={(e) => {
dragging.current = true
setFromClientX(e.clientX)
}}
onTouchStart={(e) => {
dragging.current = true
setFromClientX(e.touches[0].clientX)
}}
>
<img
src={before_after.before}
alt={before_after.before_label}
className="absolute inset-0 h-full w-full object-cover object-center"
draggable={false}
/>
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(0 0 0 ${position}%)` }} aria-hidden="true">
<img
src={before_after.after}
alt=""
className="absolute inset-0 h-full w-full object-cover object-center"
draggable={false}
/>
</div>
<span className="pointer-events-none absolute left-6 top-6 z-30 rounded-md bg-ink/85 px-4 py-2 text-xs font-light text-ivory backdrop-blur-md border border-ivory/10 shadow-lg">
{before_after.before_label}
</span>
<span className="pointer-events-none absolute right-6 top-6 z-30 rounded-md bg-ink/85 px-4 py-2 text-xs font-light text-copper backdrop-blur-md border border-copper/30 shadow-lg">
{before_after.after_label}
</span>
<div className="pointer-events-none absolute inset-y-0 z-30" style={{ left: `${position}%` }}>
<div className="h-full w-[2px] bg-copper shadow-[0_0_12px_rgba(213,135,76,0.9)]" />
</div>
<button
type="button"
role="slider"
aria-label="مقایسه کانسپت و پروژه ساخته‌شده"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(position)}
onKeyDown={onKeyDown}
onMouseDown={() => {
dragging.current = true
}}
onTouchStart={() => {
dragging.current = true
}}
className="absolute top-1/2 z-40 flex h-14 w-14 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full bg-copper text-char shadow-2xl transition-transform duration-200 ease-lux hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
style={{ left: `${position}%` }}
>
<MoveHorizontalIcon className="h-5 w-5" />
</button>
</div>
</Reveal>
</div>
</section>
)
}
+74
View File
@@ -0,0 +1,74 @@
import React, { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
type FaqProps = {
hideHeading?: boolean;
}
export function Faq({ hideHeading = false }: FaqProps) {
const { faqs } = useSite()
const [open, setOpen] = useState<number | null>(0)
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
{!hideHeading && (
<div className="lg:col-span-4">
<SectionHeading
title="سوالات متداول"
lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."
/>
</div>
)}
<div className={hideHeading ? "" : "lg:col-span-8"}>
<ul>
{faqs.map((faq, i) => {
const isOpen = open === i
return (
<Reveal key={faq.question} delay={0.04 * i}>
<li className="border-b border-ivory/10">
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
isOpen ? 'rotate-45' : ''
}`}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
</motion.div>
)}
</AnimatePresence>
</li>
</Reveal>
)
})}
</ul>
</div>
</div>
</section>
)
}
@@ -0,0 +1,92 @@
import React, { useState } from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import type { ProjectStatus } from '../../data/site'
const FILTERS: { key: ProjectStatus; label: string }[] = [
{ key: 'done', label: 'انجام شده' },
{ key: 'progress', label: 'در حال انجام' },
]
export function FeaturedProjects() {
const { projects } = useSite()
const [status, setStatus] = useState<ProjectStatus>('done')
const list = projects.filter((p) => p.status === status).slice(0, 3)
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title="پروژه‌های شاخص"
lead="کارهایی که بیشترین چیز را درباره استاندارد ما می‌گویند؛ چه آن‌هایی که تحویل شده‌اند و چه آن‌هایی که همین امروز در حال ساخت‌اند."
/>
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter) => (
<button
key={filter.key}
type="button"
role="tab"
aria-selected={status === filter.key}
onClick={() => setStatus(filter.key)}
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-out ${
status === filter.key
? 'border-copper bg-copper font-bold text-char'
: 'border-ivory/15 font-light text-ivory/65'
}`}
>
{status !== filter.key && (
<span
aria-hidden="true"
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-out group-hover:translate-y-0"
/>
)}
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
{filter.label}
</span>
</button>
))}
</div>
<div className="mt-10 min-h-[420px]">
<AnimatePresence mode="wait">
<motion.div
key={status}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="grid gap-6 lg:grid-cols-3"
>
{list.map((project, i) => (
<motion.div
key={project.slug}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full"
>
<ProjectCard project={project} />
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to="/projects" variant="outline" className="font-light">
همه پروژه‌ها
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</div>
</Reveal>
</div>
</section>
)
}
+119
View File
@@ -0,0 +1,119 @@
import React, { useCallback, useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { IMAGES } from '../../data/site'
const GRID = [IMAGES[0], IMAGES[1], IMAGES[2], IMAGES[3], IMAGES[4], IMAGES[1]]
export function Gallery() {
const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), [])
const step = useCallback((dir: number) => {
setActive((prev) => (prev === null ? prev : (prev + dir + GRID.length) % GRID.length))
}, [])
useEffect(() => {
if (active === null) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
if (e.key === 'ArrowRight') step(-1)
if (e.key === 'ArrowLeft') step(1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [active, close, step])
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title="گالری تصاویر"
lead="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده آرامش پلازا."
align="center"
/>
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
{GRID.map((src, i) => (
<Reveal key={`${src}-${i}`} delay={0.04 * i}>
<button
type="button"
onClick={() => setActive(i)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`بزرگ‌نمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
>
<img
src={src}
alt={`تصویری از پروژه‌های آرامش پلازا، شماره ${(i + 1).toLocaleString('fa-IR')}`}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
</Reveal>
))}
</div>
</div>
<AnimatePresence>
{active !== null && (
<motion.div
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
role="dialog"
aria-modal="true"
aria-label="نمایش تصویر"
onClick={close}
>
<button
type="button"
onClick={close}
className="absolute left-8 top-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
aria-label="بستن"
>
<XIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(-1)
}}
className="absolute right-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:right-10"
aria-label="تصویر قبلی"
>
<ChevronRightIcon className="h-6 w-6" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(1)
}}
className="absolute left-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10"
aria-label="تصویر بعدی"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
<motion.img
key={active}
src={GRID[active]}
alt=""
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</section>
)
}
+106
View File
@@ -0,0 +1,106 @@
import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button'
import { useSite } from '../../api'
export function Hero() {
const { brand } = useSite()
const { scrollY } = useScroll()
const imageY = useTransform(scrollY, [0, 700], [0, 110])
const contentY = useTransform(scrollY, [0, 700], [0, -55])
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
return (
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی آرامش پلازا">
<motion.img
src={brand.hero}
alt="نمای شبانه برج مسکونی ساخته‌شده توسط آرامش پلازا"
style={{ y: imageY }}
className="absolute inset-0 h-[114%] w-full object-cover"
/>
<motion.div className="absolute inset-0 bg-ink" style={{ opacity: overlay }} aria-hidden="true" />
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(to top, #27221c 5%, rgba(39,34,28,0.2) 60%, rgba(39,34,28,0.6) 100%)',
}}
aria-hidden="true"
/>
<motion.div
style={{ y: contentY }}
className="relative mx-auto flex h-full max-w-site flex-col justify-center px-6 lg:px-10"
>
<div className="w-full max-w-[700px] lg:max-w-[58%]">
<motion.p
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
className="text-sm font-light text-copper"
>
از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory"
>
ساختمانی که آرامش را
<span className="text-copper"> معماری </span>
می‌کند
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/70"
>
طراحی، ساخت و سرمایه‌گذاری در پروژه‌های مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده می‌شود و در
سال‌ها دوام می‌آورد.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4"
>
<ButtonLink to="/projects" variant="copper">
مشاهده پروژه‌ها
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
<ButtonLink to="/contact" variant="outline" className="font-light">
مشاوره سرمایه‌گذاری
</ButtonLink>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 left-6 hidden w-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>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
پروژه تحویل‌شده در بیست‌ویک سال فعالیت حرفه‌ای
</p>
</motion.div>
</motion.div>
<a
href="#story"
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"
>
<span className="text-xs font-light">اسکرول کنید</span>
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
</a>
</section>
)
}
+135
View File
@@ -0,0 +1,135 @@
import React, { useState } from 'react'
import { CheckIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from '../Reveal'
import { Button } from '../Button'
import { API_URL } from '../../config'
export function Newsletter() {
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
const [sending, setSending] = useState(false)
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
title: title,
text: text,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-[8px] bg-ink/60',
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
},
showClass: {
popup: 'swal2-show',
backdrop: 'swal2-backdrop-show',
},
hideClass: {
popup: 'swal2-hide',
backdrop: 'swal2-backdrop-hide',
}
})
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (sending) return
const emailValue = email.trim()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailValue) {
setStatus('error')
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
return
}
if (!emailRegex.test(emailValue)) {
setStatus('error')
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
return
}
setSending(true)
try {
const res = await fetch(`${API_URL}/api/newsletter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailValue }),
})
if (!res.ok) {
setStatus('error')
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در عضویت', detail)
return
}
setStatus('done')
setEmail('')
} catch {
setStatus('error')
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally {
setSending(false)
}
}
return (
<section className="bg-copper py-16" aria-labelledby="newsletter-title">
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
<Reveal>
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
خبرنامه آرامش پلازا
</h2>
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
هر ماه یک ایمیل: پروژه‌های جدید، فرصت‌های سرمایه‌گذاری و یادداشت‌های تیم معماری.
</p>
</Reveal>
<Reveal delay={0.08}>
<form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row" noValidate>
<div className="flex-1">
<label htmlFor="newsletter-email" className="sr-only">
نشانی ایمیل
</label>
<input
id="newsletter-email"
type="email"
dir="ltr"
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (status !== 'idle') setStatus('idle')
}}
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"
aria-invalid={status === 'error'}
/>
{status === 'done' && (
<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" />
عضویت شما ثبت شد.
</p>
)}
</div>
<Button type="submit" variant="dark-ivory" disabled={sending}>
<SendIcon className="h-4 w-4" />
{sending ? 'در حال ارسال…' : 'عضویت'}
</Button>
</form>
</Reveal>
</div>
</section>
)
}
+42
View File
@@ -0,0 +1,42 @@
import React from 'react'
import { Reveal } from '../Reveal'
import { ServiceIcon } from '../ServiceIcon'
import { useSite } from '../../api'
export function Services() {
const { services } = useSite()
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right">
<h2 id="services-title" className="text-section font-bold text-ivory">
خدمات
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام
کنترل کیفیت.
</p>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{services.map((service, i) => (
<Reveal key={service.title} delay={0.04 * i} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<ServiceIcon
name={service.icon}
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
/>
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{service.title}
</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
</article>
</Reveal>
))}
</div>
</div>
</section>
)
}
+47
View File
@@ -0,0 +1,47 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion';
import { useSite } from '../../api';
function toFa(n: number) {
return n.toLocaleString('fa-IR');
}
function Counter({ value }: {value: number;}) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: '-60px' });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!inView) return;
let frame = 0;
const total = 60;
const id = window.setInterval(() => {
frame += 1;
const progress = 1 - Math.pow(1 - frame / total, 3);
setDisplay(Math.round(value * progress));
if (frame >= total) window.clearInterval(id);
}, 20);
return () => window.clearInterval(id);
}, [inView, value]);
return <span ref={ref}>{toFa(display)}</span>;
}
export function Stats() {
const { stats } = useSite();
return (
<section className="bg-char py-16" aria-label="آمار و ارقام">
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
{stats.map((stat) =>
<div key={stat.label} className="text-center">
<p className="text-4xl font-bold text-copper md:text-5xl">
<Counter value={stat.value} />
{stat.suffix && <span className="mr-1 text-2xl">{stat.suffix}</span>}
</p>
<p className="mt-3 text-sm font-light text-ivory/55">{stat.label}</p>
</div>
)}
</div>
</section>);
}
+91
View File
@@ -0,0 +1,91 @@
import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { IMAGES } from '../../data/site'
const BENTO = [
{ src: IMAGES[0], alt: 'نمای برج مسکونی آرامش در تهران' },
{ src: IMAGES[1], alt: 'آتریوم مرکزی مجتمع تجاری پارسه' },
{ src: IMAGES[2], alt: 'ویلاهای ساخته‌شده در سواحل شمال' },
{ src: IMAGES[3], alt: 'نمای شبانه برج دوقلوی نیاوران' },
]
type StoryProps = {
showLink?: boolean;
showLabel?: boolean;
alignTop?: boolean;
children?: React.ReactNode;
}
export function Story({ showLink = true, showLabel = true, alignTop = false, children }: StoryProps) {
return (
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title">
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
<div>
{showLabel && (
<Reveal>
<h2 id="story-title" className="text-section font-bold text-ivory">
درباره ی ما
</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
)}
<Reveal delay={0.08}>
<p className="mt-8 text-base font-light leading-9 text-ivory/65">
آرامش پلازا با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعه‌های شناخته‌شده ساخت‌وساز لوکس در ایران
است. در تمام این سال‌ها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.
</p>
<p className="mt-5 text-base font-light leading-9 text-ivory/65">
هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز می‌شود؛ سپس تیم طراحی، سازه و اجرا در یک
فرآیند واحد آن را تا تحویل کلید پیش می‌برند.
</p>
</Reveal>
<Reveal delay={0.16}>
<div className="mt-10 grid grid-cols-2 gap-8 border-t border-ivory/10 pt-8">
<div>
<p className="text-2xl font-bold text-copper">۹۴٪</p>
<p className="mt-2 text-sm font-light text-ivory/55">تحویل به‌موقع پروژه‌ها</p>
</div>
<div>
<p className="text-2xl font-bold text-copper">۵ سال</p>
<p className="mt-2 text-sm font-light text-ivory/55">پشتیبانی پس از تحویل</p>
</div>
</div>
</Reveal>
{showLink && (
<Reveal delay={0.22}>
<ButtonLink to="/about" variant="outline" className="mt-10 font-light">
داستان کامل ما
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</Reveal>
)}
{children && (
<Reveal delay={0.24}>
{children}
</Reveal>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{BENTO.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden">
<img
src={item.src}
alt={item.alt}
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105"
/>
</div>
</Reveal>
))}
</div>
</div>
</section>
)
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
export function Team() {
const { team } = useSite()
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title="تیم ما"
lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."
align="center"
/>
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{team.map((member, i) => (
<Reveal key={member.name} delay={0.06 * i} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
{member.name.trim().charAt(0)}
</div>
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
{member.bio}
</p>
</article>
</Reveal>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
import { SectionHeading } from '../SectionHeading';
import { useSite } from '../../api';
export function Testimonials() {
const { testimonials } = useSite();
const [index, setIndex] = useState(0);
const item = testimonials[index];
const go = (dir: number) => {
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
};
return (
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title="نظرات مشتریان" align="center" />
<div className="relative mx-auto mt-14 max-w-3xl text-center">
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
<AnimatePresence mode="wait">
<motion.blockquote
key={item.name}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{item.quote}»</p>
<footer className="mt-8">
<p className="text-base font-bold text-copper">{item.name}</p>
<p className="mt-1 text-sm font-light text-ivory/45">{item.role}</p>
</footer>
</motion.blockquote>
</AnimatePresence>
<div className="mt-10 flex items-center justify-center gap-4">
<button
type="button"
onClick={() => go(1)}
aria-label="نظر قبلی"
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<ChevronRightIcon className="h-5 w-5" />
</button>
<div className="flex gap-2" role="tablist" aria-label="انتخاب نظر">
{testimonials.map((t, i) =>
<button
key={t.name}
type="button"
role="tab"
aria-selected={i === index}
aria-label={`نظر ${t.name}`}
onClick={() => setIndex(i)}
className={`h-1.5 transition-[width,background-color] duration-200 ease-lux ${
i === index ? 'w-8 bg-copper' : 'w-3 bg-ivory/25'}`
} />
)}
</div>
<button
type="button"
onClick={() => go(-1)}
aria-label="نظر بعدی"
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</section>);
}
+85
View File
@@ -0,0 +1,85 @@
import React, { useRef } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSite, type TimelineItem } from '../../api'
import { SectionHeading } from '../SectionHeading'
const ease = [0.23, 1, 0.32, 1] as const
type Milestone = TimelineItem
function Item({ item, index }: { item: Milestone; index: number }) {
const ref = useRef<HTMLLIElement>(null)
const active = useInView(ref, { once: true, amount: 0.6 })
const onRight = index % 2 === 0
return (
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
<span
aria-hidden="true"
className={`absolute right-[-5px] top-6 z-10 h-2.5 w-2.5 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
active
? 'bg-copper shadow-[0_0_0_5px_rgba(213,135,76,0.22)]'
: 'bg-ivory/25'
}`}
/>
<motion.div
className={`pr-8 md:pr-0 ${
onRight ? 'md:col-start-1 md:pl-16 md:text-left' : 'md:col-start-2 md:pr-16'
}`}
initial={{ opacity: 0, y: 24 }}
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.55, ease }}
>
<div className="border border-ivory/10 bg-ink p-6 md:p-7">
<span className="text-sm font-light text-copper">{item.year}</span>
<h3 className="mt-3 text-lg font-bold text-ivory">{item.title}</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/60">{item.desc}</p>
</div>
</motion.div>
</li>
)
}
export function Timeline() {
const { timeline } = useSite()
const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start 70%', 'end 60%'],
})
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
return (
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
<div className="mb-16">
<SectionHeading
title="مسیر ما"
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."
align="center"
/>
</div>
<div ref={ref} className="relative mx-auto max-w-4xl">
<span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px bg-ivory/12 md:right-1/2"
/>
<motion.span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px origin-top bg-copper md:right-1/2"
style={{ scaleY }}
/>
<ol className="flex flex-col gap-12 md:gap-16">
{timeline.map((item, i) => (
<Item key={item.year} item={item} index={i} />
))}
</ol>
</div>
</div>
</section>
)
}
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { Reveal } from '../Reveal';
import { SectionHeading } from '../SectionHeading';
import { IMAGES } from '../../data/site';
import { useSite } from '../../api';
export function WhyUs() {
const { why_us } = useSite();
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
<div className="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
<div className="lg:col-span-5">
<SectionHeading
title="چرا آرامش پلازا"
lead="شش دلیلی که کارفرمایان و سرمایه‌گذاران بار دوم هم به ما برمی‌گردند." />
<Reveal delay={0.12}>
<img
src={IMAGES[3]}
alt="نمای شبانه برج دوقلوی نیاوران"
className="mt-10 h-80 w-full object-cover" />
</Reveal>
</div>
<div className="lg:col-span-7">
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
{why_us.map((item, i) =>
<Reveal key={item.title} delay={0.05 * i}>
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
<h3 className="mt-4 text-lg font-bold text-ivory">{item.title}</h3>
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{item.desc}</p>
</div>
</Reveal>
)}
</div>
</div>
</div>
</section>);
}
+3
View File
@@ -0,0 +1,3 @@
// آدرس بک‌اند FastAPI — در محیط تولید مقدار VITE_API_URL را در فایل .env تنظیم کنید.
export const API_URL: string =
(import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8000'
+146
View File
@@ -0,0 +1,146 @@
export const BRAND = {
name: 'آرامش پلازا',
logo: 'https://i.postimg.cc/XYNrsHsM/Chat-GPT-Image-Aug-6-2026-04-34-46-PM.png',
hero: 'https://i.postimg.cc/J0bKgQVg/Gemini-Generated-Image-mdh969mdh969mdh9.png',
contactBg: 'https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png',
phone: '۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰',
phoneHref: 'tel:+982188742210',
email: 'info@arameshplaza.com',
address: 'تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم'
};
export const IMAGES = [
'https://i.postimg.cc/wTQ0QFkG/Gemini-Generated-Image-1tjszd1tjszd1tjs.png',
'https://i.postimg.cc/1zfBPS1G/Gemini-Generated-Image-at69yhat69yhat69.png',
'https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png',
'https://i.postimg.cc/4xFvW0HX/Gemini-Generated-Image-n7mhonn7mhonn7mh.png',
'https://i.postimg.cc/N036cr4s/Gemini-Generated-Image-ytbhvvytbhvvytbh.png'
];
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 = {
title: string;
desc: string;
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 Project = {
slug: string;
title: string;
location: string;
year: string;
status: ProjectStatus;
image: string;
gallery: string[];
area: string;
floors: string;
units: string;
client: string;
summary: string;
body: string[];
};
export const STATUS_LABEL: Record<ProjectStatus, string> = {
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: 'https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_before.webp',
after: 'https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_after.webp',
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 = {
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string; body: 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: 'در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژه‌ای نیز در جزیره کیش در دست ساخت است.' }
];
+42
View File
@@ -0,0 +1,42 @@
/* @import url() FONT IMPORTS MUST ALWAYS BE AT THE VERY TOP OF THIS FILE, ABOVE THE TAILWIND IMPORTS — DO NOT DELETE THIS COMMENT */
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap');
/* CRITICAL: THE FOLLOWING TAILWIND IMPORTS MUST NEVER BE DELETED OR REORDERED — DO NOT DELETE THIS COMMENT */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
@font-face {
font-family: 'Pinar';
src: url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar.woff2') format('woff2'),
url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar.woff') format('woff');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'Pinar';
src: url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar-Bold.woff2') format('woff2'),
url('https://cdn.fontcdn.ir/Font/Persian/Pinar/Pinar-Bold.woff') format('woff');
font-weight: 700;
font-display: swap;
}
html {
scroll-behavior: smooth;
}
body {
background-color: #27221c;
color: #f5f3ef;
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
-webkit-font-smoothing: antialiased;
}
::selection {
background-color: #d5874c;
color: #1e1a16;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
+9
View File
@@ -0,0 +1,9 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
const rootEl = document.getElementById("root");
if (rootEl) {
ReactDOM.createRoot(rootEl).render(<App />);
}
+14
View File
@@ -0,0 +1,14 @@
{
"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"
}
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { Story } from '../components/home/Story'
import { Stats } from '../components/home/Stats'
import { Timeline } from '../components/home/Timeline'
import { WhyUs } from '../components/home/WhyUs'
import { Team } from '../components/home/Team'
import { ContactSection } from '../components/ContactSection'
import { IMAGES } from '../data/site'
export function About() {
return (
<>
<PageHero
title="درباره ما"
lead="آرامش پلازا مجموعه‌ای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساخت‌وساز لوکس فعالیت می‌کند."
image={IMAGES[3]}
/>
<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 />
<Timeline />
<WhyUs />
<Team />
<ContactSection />
</>
)
}
+95
View File
@@ -0,0 +1,95 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard';
import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api';
export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>();
const { articles } = useSite();
const article = articles.find((a) => a.slug === slug);
if (!article) {
return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">این مقاله پیدا نشد</h1>
<p className="mt-4 text-sm font-light text-ivory/60">فهرست کامل مقالات وبلاگ را ببینید.</p>
<ButtonLink to="/blog" variant="copper" className="mt-8">
بازگشت به وبلاگ
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</section>);
}
const related = articles.filter((a) => a.slug !== article.slug).slice(0, 3);
return (
<>
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<img src={article.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true" />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
صفحه اصلی
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<Link to="/blog" className="transition-colors hover:text-copper">
وبلاگ
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{article.category}</span>
</nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
<div className="mt-6 flex items-center gap-5 text-xs font-light text-ivory/60">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
</span>
</div>
</div>
</section>
<article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-3xl px-6">
<Reveal>
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80">
{article.excerpt}
</p>
</Reveal>
{article.body.map((paragraph, i) =>
<Reveal key={paragraph} delay={0.03 * i}>
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
</Reveal>
)}
</div>
</article>
<section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط">
<div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">مقالات مرتبط</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) =>
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
<ArticleCard article={item} />
</Reveal>
)}
</div>
</div>
</section>
<Newsletter />
</>);
}
+74
View File
@@ -0,0 +1,74 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import { PageHero } from '../components/PageHero'
import { Reveal } from '../components/Reveal'
import { ArticleCard } from '../components/ArticleCard'
import { Newsletter } from '../components/home/Newsletter'
import { IMAGES } from '../data/site'
import { useSite } from '../api'
export function Blog() {
const { articles } = useSite()
const [featured, ...rest] = articles
return (
<>
<PageHero
title="وبلاگ"
lead="یادداشت‌های تیم معماری، اجرا و سرمایه‌گذاری آرامش پلازا درباره آنچه در کارگاه و بازار می‌گذرد."
image={IMAGES[2]}
/>
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal>
<Link
to={`/blog/${featured.slug}`}
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
aria-label={`خواندن مقاله ${featured.title}`}
>
<div className="overflow-hidden">
<img
src={featured.image}
alt={`تصویر مقاله ${featured.title}`}
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</div>
<div className="flex flex-col justify-center p-8 lg:p-12">
<p className="text-xs font-light text-copper">مقاله ویژه — {featured.category}</p>
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
{featured.title}
</h2>
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{featured.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{featured.read}
</span>
</span>
<span className="flex items-center gap-2 text-copper">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-200 ease-lux group-hover:-translate-x-1" />
</span>
</div>
</div>
</Link>
</Reveal>
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{rest.map((article, i) => (
<Reveal key={article.slug} delay={0.05 * i} className="h-full">
<ArticleCard article={article} />
</Reveal>
))}
</div>
</div>
</section>
<Newsletter />
</>
)
}
+44
View File
@@ -0,0 +1,44 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal'
import { IMAGES } from '../data/site'
const OFFICES = [
{ city: 'تهران', address: 'خیابان ولیعصر، برج آرامش، طبقه هجدهم', hours: 'شنبه تا چهارشنبه، ۹ تا ۱۸' },
{ city: 'اصفهان', address: 'خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم', hours: 'شنبه تا چهارشنبه، ۹ تا ۱۷' },
{ city: 'نوشهر', address: 'بلوار دریا، دفتر پروژه دریاکنار', hours: 'شنبه تا پنجشنبه، ۱۰ تا ۱۶' },
]
export function ContactPage() {
return (
<>
<PageHero
title="تماس با ما"
lead="برای مشاوره ساخت، سرمایه‌گذاری یا بازدید از پروژه‌ها، تیم ما در سه شهر آماده پاسخ‌گویی است."
image={IMAGES[1]}
/>
<section className="bg-ink py-24" aria-label="دفاتر ما">
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{OFFICES.map((office, i) => (
<Reveal key={office.city} delay={0.06 * i} className="h-full">
{/* اعمال فرمول برای نرم شدن بالا آمدن کارت، بدون هیچ تغییری روی متن‌ها */}
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
<div className="mt-auto pt-10">
<p className="border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
{office.hours}
</p>
</div>
</article>
</Reveal>
))}
</div>
</section>
<ContactSection />
</>
)
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { Faq } from '../components/home/Faq'
import { ContactSection } from '../components/ContactSection'
import { IMAGES } from '../data/site'
export function FaqPage() {
return (
<>
<PageHero
title="سوالات متداول"
lead="پیش از تماس، شاید پاسخ پرسش شما همین‌جا باشد. اگر نبود، خوشحال می‌شویم مستقیم صحبت کنیم."
image={IMAGES[1]}
/>
<Faq hideHeading={true} />
<ContactSection />
</>
)
}
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { Hero } from '../components/home/Hero';
import { Story } from '../components/home/Story';
import { Stats } from '../components/home/Stats';
import { Services } from '../components/home/Services';
import { BeforeAfter } from '../components/home/BeforeAfter';
import { FeaturedProjects } from '../components/home/FeaturedProjects';
import { Gallery } from '../components/home/Gallery';
import { Timeline } from '../components/home/Timeline';
import { WhyUs } from '../components/home/WhyUs';
import { Testimonials } from '../components/home/Testimonials';
import { Team } from '../components/home/Team';
import { Articles } from '../components/home/Articles';
import { Faq } from '../components/home/Faq';
import { Newsletter } from '../components/home/Newsletter';
import { ContactSection } from '../components/ContactSection';
export function Home() {
return (
<>
<Hero />
<Story />
<Stats />
<Services />
<BeforeAfter />
<FeaturedProjects />
<Gallery />
<Timeline />
<WhyUs />
<Testimonials />
<Team />
<Articles />
<Faq />
<Newsletter />
<ContactSection />
</>);
}
+75
View File
@@ -0,0 +1,75 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { Reveal } from '../components/Reveal'
import { SectionHeading } from '../components/SectionHeading'
import { Stats } from '../components/home/Stats'
import { ContactSection } from '../components/ContactSection'
import { IMAGES } from '../data/site'
const MODELS = [
{
title: 'مشارکت در ساخت',
desc: 'مالک زمین و آرامش پلازا در سود پروژه شریک می‌شوند؛ اجرا، مجوزها و فروش بر عهده ماست.',
points: ['ارزیابی رایگان زمین', 'قرارداد شفاف با ضمانت اجرا', 'گزارش ماهانه پیشرفت'],
},
{
title: 'صندوق پروژه‌محور',
desc: 'سرمایه‌گذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبه‌شده در پایان دوره.',
points: ['حداقل دوره ۲۴ ماه', 'گزارش ارزش‌گذاری فصلی', 'امکان انتقال واحد سرمایه'],
},
{
title: 'پیش‌خرید واحد',
desc: 'خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحله‌ای.',
points: ['پرداخت مرحله‌ای متناسب با پیشرفت', 'امکان شخصی‌سازی پلان', 'تضمین زمان تحویل'],
},
]
export function Investment() {
return (
<>
<PageHero
title="سرمایه‌گذاری"
lead="سه مسیر روشن برای ورود به بازار ساخت‌وساز لوکس، با ساختار مالی شفاف و گزارش‌دهی منظم."
image={IMAGES[4]}
/>
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title="مدل‌های همکاری"
lead="بسته به میزان سرمایه، افق زمانی و سطح ریسکی که می‌پذیرید، یکی از این سه مسیر مناسب شماست."
/>
<div className="mt-14 grid gap-6 lg:grid-cols-3">
{MODELS.map((model, i) => (
<Reveal key={model.title} delay={0.06 * i} className="h-full">
<article className="group flex h-full flex-col border border-ivory/10 bg-char p-10 transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<p className="text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
<h3 className="mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{model.title}
</h3>
<p className="mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p>
<div className="mt-auto pt-10">
<ul className="space-y-4 border-t border-ivory/10 pt-8">
{model.points.map((point) => (
<li key={point} className="flex items-start gap-3 text-sm font-light leading-8 text-ivory/65">
<span className="mt-3.5 h-1 w-1 shrink-0 rounded-full bg-copper transition-transform duration-300 ease-out group-hover:scale-[1.5]" aria-hidden="true" />
{point}
</li>
))}
</ul>
</div>
</article>
</Reveal>
))}
</div>
</div>
</section>
<Stats />
<ContactSection />
</>
)
}
+160
View File
@@ -0,0 +1,160 @@
import React, { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
import { Reveal } from '../components/Reveal'
import { ButtonLink } from '../components/Button'
import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox'
import { STATUS_LABEL } from '../data/site'
import { useSite } from '../api'
export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>()
const { projects } = useSite()
const project = projects.find((p) => p.slug === slug)
const [lightbox, setLightbox] = useState<number | null>(null)
if (!project) {
return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">این پروژه پیدا نشد</h1>
<p className="mt-4 text-sm font-light text-ivory/60">
ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.
</p>
<ButtonLink to="/projects" variant="copper" className="mt-8">
بازگشت به پروژه‌ها
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</section>
)
}
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
const facts = [
{ label: 'موقعیت', value: project.location },
{ label: 'سال', value: project.year },
{ label: 'زیربنا', value: project.area },
{ label: 'تعداد طبقات', value: project.floors },
{ label: 'واحدها', value: project.units },
{ label: 'کارفرما', value: project.client },
]
return (
<>
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
صفحه اصلی
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<Link to="/projects" className="transition-colors hover:text-copper">
پروژه‌ها
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{project.title}</span>
</nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
</div>
</section>
<section className="bg-char py-20 lg:py-28" aria-label="جزئیات پروژه">
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
<Reveal className="order-1">
<span className="flex items-center gap-3 text-sm font-light text-copper">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{STATUS_LABEL[project.status]}
</span>
<h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2>
{project.body.map((para) => (
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 text-right">
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
<div className="mt-10">
<ButtonLink to="/contact" variant="outline" className="font-light">
پروژه‌ای مشابه دارید؟
</ButtonLink>
</div>
</Reveal>
<Reveal delay={0.1} className="order-2">
<button
type="button"
onClick={() => setLightbox(0)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={project.gallery[0] || project.image}
alt=""
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
<div className="mt-4 grid grid-cols-3 gap-4">
{project.gallery.slice(1).map((img, i) => (
<button
key={img}
type="button"
onClick={() => setLightbox(i + 1)}
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={img}
alt=""
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
))}
</div>
</Reveal>
</div>
</section>
<section className="bg-ink py-20 lg:py-28" aria-label="پروژه‌های مرتبط">
<div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">پروژه‌های دیگر</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => (
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
<ProjectCard project={item} />
</Reveal>
))}
</div>
</div>
</section>
<Lightbox
images={project.gallery}
index={lightbox}
onClose={() => setLightbox(null)}
onChange={setLightbox}
/>
<ContactSection />
</>
)
}
+71
View File
@@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { PageHero } from '../components/PageHero';
import { Reveal } from '../components/Reveal';
import { ProjectCard } from '../components/ProjectCard';
import { Gallery } from '../components/home/Gallery';
import { ContactSection } from '../components/ContactSection';
import { IMAGES, type ProjectStatus } from '../data/site';
import { useSite } from '../api';
const FILTERS: {key: ProjectStatus;label: string;}[] = [
{ key: 'done', label: 'انجام شده' },
{ key: 'progress', label: 'در حال انجام' }];
export function Projects() {
const { projects } = useSite();
const [status, setStatus] = useState<ProjectStatus>('done');
const list = projects.filter((p) => p.status === status);
return (
<>
<PageHero
title="پروژه‌ها"
lead="مجموعه‌ای از برج‌های مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژه‌های تحویل‌شده تا کارگاه‌های فعال امروز."
image={IMAGES[0]} />
<section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژه‌ها">
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="flex flex-wrap gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter) =>
<button
key={filter.key}
type="button"
role="tab"
aria-selected={status === filter.key}
onClick={() => setStatus(filter.key)}
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-lux ${
status === filter.key ?
'border-copper bg-copper font-bold text-char' :
'border-ivory/15 font-light text-ivory/65'}`
}>
{status !== filter.key &&
<span
aria-hidden="true"
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0" />
}
<span className="relative z-10 transition-colors duration-300 ease-lux group-hover:text-char">
{filter.label}
</span>
</button>
)}
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{list.map((project, i) =>
<Reveal key={project.slug} delay={0.05 * i} className="h-full">
<ProjectCard project={project} imageClass="h-80" />
</Reveal>
)}
</div>
</div>
</section>
<Gallery />
<ContactSection />
</>);
}
+49
View File
@@ -0,0 +1,49 @@
import React from 'react'
import { PageHero } from '../components/PageHero'
import { Services } from '../components/home/Services'
import { Faq } from '../components/home/Faq'
import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal'
import { SectionHeading } from '../components/SectionHeading'
import { IMAGES } from '../data/site'
const PROCESS = [
{ step: '۰۱', title: 'مطالعه و امکان‌سنجی', desc: 'بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.' },
{ step: '۰۲', title: 'طراحی و فاز اجرایی', desc: 'کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشه‌های اجرایی.' },
{ step: '۰۳', title: 'اجرا و کنترل کیفیت', desc: 'ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.' },
{ step: '۰۴', title: 'تحویل و پشتیبانی', desc: 'تحویل کلید، آموزش بهره‌برداری و پنج سال پشتیبانی فنی.' },
]
export function ServicesPage() {
return (
<>
<PageHero
title="خدمات"
lead="هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهره‌برداری پوشش می‌دهند."
image={IMAGES[1]}
/>
<Services />
<section className="bg-char py-24 lg:py-32" aria-labelledby="process-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title="فرآیند کار ما" lead="چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید." align="center" />
<div className="mt-14 grid gap-px bg-ivory/10 md:grid-cols-2 lg:grid-cols-4">
{PROCESS.map((item, i) => (
<Reveal key={item.step} delay={0.06 * i} className="h-full">
<div className="flex h-full flex-col justify-center bg-char px-6 py-14 text-center">
<p className="text-4xl font-bold text-copper">{item.step}</p>
<h3 className="mt-6 text-xl font-bold text-ivory">{item.title}</h3>
<p className="mt-4 text-base font-light leading-9 text-ivory/60">{item.desc}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
<Faq />
<ContactSection />
</>
)
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+31
View File
@@ -0,0 +1,31 @@
export default {
content: ['./**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
ink: '#27221c',
char: '#1e1a16',
copper: '#d5874c',
ivory: '#F5F3EF',
},
fontFamily: {
pinar: ['Pinar', 'Vazirmatn', 'Tahoma', 'sans-serif'],
},
transitionTimingFunction: {
lux: 'cubic-bezier(0.23, 1, 0.32, 1)',
},
boxShadow: {
lift: '0 24px 50px -18px rgba(0, 0, 0, 0.75)',
},
fontSize: {
// سایز این بخش یک درجه کوچک‌تر شد (هم در موبایل و هم در دسکتاپ)
hero: ['clamp(1.75rem, 3.8vw, 3.5rem)', { lineHeight: '1.35' }],
section: ['clamp(1.6rem, 2.4vw, 2.4rem)', { lineHeight: '1.4' }],
},
maxWidth: {
site: '1440px',
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})