This commit is contained in:
amirhosein.ashourloo
2026-08-09 12:05:39 +03:30
commit 6207bb8431
76 changed files with 5127 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
/node_modules
/.next/
/.claude/
/out/
/build
*.pem
.env*
!.env.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
*.tsbuildinfo
next-env.d.ts
+165
View File
@@ -0,0 +1,165 @@
# گروه ساختمانی رئوفی — وب‌سایت شرکتی
بازطراحی صفحه اصلی سایت [raoofigroup.com](https://www.raoofigroup.com/) با Next.js.
- **زبان:** فارسی (`lang="fa"`)
- **جهت:** راست‌به‌چپ (`dir="rtl"`)
- **فریم‌ورک:** Next.js 15 (App Router) + TypeScript + Tailwind CSS v4
---
## اجرا
```bash
npm install
```
```bash
npm run dev
```
سپس <http://localhost:3000>
```bash
npm run build
```
---
## ساختار
```
src/
├── app/
│ ├── layout.tsx # ریشه — تنظیم lang/dir، متادیتا، فونت‌ها
│ ├── page.tsx # صفحه اصلی
│ ├── aboutus/page.tsx # درباره ما
│ ├── our-services/page.tsx # خدمات ما
│ ├── fonts.ts # بارگذاری محلی ایران‌یکان و Montserrat
│ └── globals.css # توکن‌های رنگ/تایپوگرافی و انیمیشن‌ها (Tailwind v4 @theme)
├── components/
│ ├── shared/ # اجزای مشترک صفحات داخلی
│ │ ├── PageHero.tsx # هدر تمام‌صفحه + بردکرامب
│ │ ├── Editorial.tsx # ستون مرکزی: کپشن → تیتر → متن
│ │ ├── FullBleed.tsx # تصویر تمام‌عرض ۱۶:۹
│ │ ├── ProjectGallery.tsx# کروسل پروژه‌ها
│ │ └── PageCta.tsx # بخش پایانی تیره
│ ├── about/
│ │ └── Pillars.tsx # سه بنیان
│ ├── services/
│ │ └── ServiceGrid.tsx # گرید ۳×۲ شش حوزه تخصصی
│ ├── SiteHeader.tsx # هدر شناور + منوی موبایل
│ ├── Hero.tsx # اسلایدر تمام‌صفحه (نسخه دسکتاپ/موبایل)
│ ├── About.tsx # معرفی + آمار کلیدی
│ ├── Marquee.tsx # نوار متحرک BUILDING YOUR DREAMS
│ ├── Projects.tsx # کروسل ۱۵ پروژه (اسکرول افقی RTL)
│ ├── Services.tsx # شش حوزه خدمات
│ ├── ServiceIcons.tsx # آیکون‌های خطی خدمات (SVG درون‌خطی)
│ ├── News.tsx # سه خبر آخر
│ ├── ContactCta.tsx # بخش دعوت به تماس
│ ├── SiteFooter.tsx # فوتر
│ └── Reveal.tsx # انیمیشن ظاهرشدن هنگام اسکرول
├── data/
│ └── site.ts # تمام محتوا و متن‌ها در یک فایل
└── assets/
├── images/ # تصاویر دانلودشده از سایت فعلی (بدون تغییر)
└── fonts/ # ایران‌یکان + Montserrat
```
## تصاویر
تمام تصاویر از سایت فعلی دانلود و **بدون هیچ تغییری** (بدون فشرده‌سازی، تغییر اندازه یا
تبدیل فرمت) در `src/assets/images/` ذخیره شده‌اند. فقط نام فایل‌ها برای خوانایی
تغییر کرده است.
از آنجا که تصاویر داخل `src/` قرار دارند (نه `public/`)، با `import` ایستا استفاده
می‌شوند؛ به این ترتیب `next/image` ابعاد را در زمان build می‌شناسد، `placeholder="blur"`
خودکار تولید می‌شود و خروجی در زمان سرو به AVIF/WebP بهینه می‌شود.
| بخش | مسیر | منبع |
| --- | --- | --- |
| لوگو | `images/brand/logo.png` | `n2Q43WNHJx.png` |
| اسلایدر (دسکتاپ) | `images/hero/hero-{1,2,3}.jpg` | `FRJAsJUMeL`, `itzQB5xwKn`, `ttGmf0CgcC` |
| اسلایدر (موبایل) | `images/hero/hero-{1,2,3}-mobile.jpg` | `4qKc62Tffq`, `a3NXbr7zHb`, `qrTNGmxAeG` |
| درباره ما | `images/about/about.jpg` | `PlUVEXnNA7.jpg` |
| پروژه‌ها (۱۵) | `images/projects/*.jpg` | گالری پروژه‌های سایت |
| اخبار (۳) | `images/news/*.jpg` | سه خبر آخر سایت |
### آیکون خدمات
شش آیکون خدمات در سایت فعلی از CDN پاسخ **۴۰۴** می‌گیرند و آنجا هم به‌صورت
تصویر شکسته (۰×۰) رندر می‌شوند. به جای آن‌ها آیکون‌های خطی SVG در
`src/components/ServiceIcons.tsx` طراحی شده که رنگ طلایی برند را به ارث می‌برند و
درخواست شبکه‌ای اضافه ندارند.
## فونت‌ها
ایران‌یکان (همان فونت سایت فعلی، نسخه `fanum` که ارقام لاتین را با اعداد فارسی
نمایش می‌دهد) و Montserrat برای عناوین لاتین — هر دو به‌صورت محلی از طریق
`next/font/local` بارگذاری می‌شوند تا وابستگی به CDN خارجی حذف شود.
## پالت رنگ
| نقش | مقدار |
| --- | --- |
| مشکی (ink) | `#0e1013` |
| کاغذی (paper) | `#fbfaf8` |
| شنی (sand) | `#f2eee8` |
| متن بدنه (muted) | `#5c6873` — برگرفته از سایت فعلی |
| طلایی (gold) | `#b08d57` |
## صفحه «درباره ما»
ظاهر این صفحه الهام‌گرفته از [omniyat.com/omniyat-atelier](https://www.omniyat.com/omniyat-atelier)
است، اما **تمام محتوا از صفحه درباره ما سایت رئوفی** برداشته شده است.
سیستم چیدمانِ برگرفته از مرجع:
| ویژگی | مرجع (Omniyat) | پیاده‌سازی |
| --- | --- | --- |
| ریتم عمودی بلوک متن | `192px 48px` | `192px 48px` (موبایل: `96px 24px`) |
| کپشن بالای تیتر | ۱۴px، `letter-spacing: 1.4px` | همان — فقط روی بخش لاتین |
| تیتر بخش | ۴۸px، وزن سبک، `max-width: 927px` | همان |
| متن بدنه | ۱۶px، `max-width: 738px`، وسط‌چین | همان |
| پس‌زمینه بخش‌ها | سفید ↔ خاکستری روشن | `paper``sand` |
| تصاویر | تمام‌عرض بین بلوک‌های متن | همان (۱۶:۹) |
دو تفاوت عمدی برای فارسی:
- **`letter-spacing` روی متن فارسی اعمال نشده** — فاصله‌گذاری حروف، اتصال حروف فارسی
را می‌شکند. این ویژگی فقط روی `span`های لاتین (`OVERVIEW`، `PHILOSOPHY`، …) اعمال شده است.
- **`line-height` بازتر** (تیتر ۱.۳ و متن ۲.۱ در برابر ۱.۰۵ و ۱.۷۵ در مرجع) چون خط فارسی
به فضای عمودی بیشتری نیاز دارد.
تصویر اختصاصی این صفحه (`images/about/atelier.jpg`) از همان صفحه سایت رئوفی گرفته شده.
تصویر بنر آن صفحه در سایت فعلی **۴۰۴** می‌دهد (لینکش بدون پسوند فایل است)، بنابراین برای
هدر از یکی از تصاویر اسلایدر استفاده شده است.
## صفحه «خدمات ما»
همان سیستم چیدمان صفحه درباره ما (الهام‌گرفته از Omniyat Atelier).
**نکته‌ی مهم درباره‌ی محتوا:** صفحه‌ی خدمات سایت فعلی فقط شامل شش عنوان خدمت و تیتر
«با خدمات ما بیشتر آشنا شوید...» است — هیچ توضیحی برای خدمات ندارد و لینک همه‌ی
کارت‌ها به `/` می‌رود.
| بخش | منبع |
| --- | --- |
| تیتر صفحه، بردکرامب، شش عنوان خدمت (فارسی + لاتین) | عیناً از سایت رئوفی |
| بنر صفحه (`images/services/services-hero.jpg`) | عیناً از سایت رئوفی |
| توضیح یک‌خطی هر خدمت (`serviceDetails[].blurb`) | **نوشته‌شده برای این بازطراحی** |
| متن مقدمه و تیترهای بخش‌ها | **نوشته‌شده برای این بازطراحی** |
متن‌های نوشته‌شده صرفاً توضیح می‌دهند هر حوزه‌ی تخصصی *چیست* و هیچ ادعایی درباره‌ی
شرکت مطرح نمی‌کنند. همه در `src/data/site.ts` متمرکز شده‌اند تا با متن رسمی کارفرما
جایگزین شوند.
یک ناهماهنگی در سایت مبدأ: صفحه‌ی خدمات «طراح و معماری» نوشته ولی صفحه‌ی اصلی
«طراحی و معماری». نسخه‌ی صفحه اصلی استفاده شد.
## نکات
- لینک‌های ناوبری به مسیرهای واقعی سایت (`/portfolios`، `/news`، …) اشاره می‌کنند
ولی تاکنون فقط صفحه اصلی و درباره ما طراحی شده‌اند.
- انیمیشن‌ها به `prefers-reduced-motion` احترام می‌گذارند.
- کروسل پروژه‌ها برای RTL نوشته شده (`scrollLeft` منفی) و با کیبورد و لمس کار می‌کند.
+16
View File
@@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
// Every quality value used across the page. Required from Next.js 16 on.
qualities: [70, 72, 75, 80, 82, 88, 90],
// Portfolio media served live from the meshkee API.
remotePatterns: [
{ protocol: "https", hostname: "meshkee-storage.sas.amin.parminstorage.ir" },
{ protocol: "https", hostname: "cdn.meshkee.com" },
],
},
};
export default nextConfig;
+1652
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "raoofigroup-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "15.5.4",
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4.1.13",
"typescript": "^5"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+115
View File
@@ -0,0 +1,115 @@
import type { Metadata } from "next";
import atelier from "@/assets/images/about/atelier.jpg";
import about from "@/assets/images/about/about.jpg";
import heroImage from "@/assets/images/hero/hero-2.jpg";
import heroImageMobile from "@/assets/images/hero/hero-2-mobile.jpg";
import { Pillars } from "@/components/about/Pillars";
import { Editorial } from "@/components/shared/Editorial";
import { FullBleed } from "@/components/shared/FullBleed";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { ProjectGallery } from "@/components/shared/ProjectGallery";
import { Reveal } from "@/components/Reveal";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { aboutPage } from "@/data/site";
import { getPortfolios, toDisplayProject } from "@/lib/api";
export const metadata: Metadata = {
title: "درباره ما",
description:
"از سال ۱۳۷۹، دیدگاه مهندس قاسم رئوفی‌منش مسیر گروه رئوفی را تعریف کرده است؛ دیدگاهی که هر پروژه را به تجربه‌ای اصیل و ماندگار از سبک زندگی ارتقاء می‌دهد.",
};
export default async function AboutPage() {
const portfolios = await getPortfolios();
const displayProjects = portfolios.map(toDisplayProject);
return (
<>
<SiteHeader />
<main>
<PageHero
image={heroImage}
imageMobile={heroImageMobile}
caption={aboutPage.hero.caption}
title={aboutPage.hero.title}
breadcrumb={aboutPage.hero.breadcrumb}
/>
{/* Overview */}
<section className="bg-paper">
<Editorial
caption={aboutPage.overview.caption}
captionLatin={aboutPage.overview.captionLatin}
title={aboutPage.overview.title}
body={aboutPage.overview.body}
/>
<FullBleed image={atelier} alt="معماری پروژه‌های گروه ساختمانی رئوفی" />
</section>
{/* Philosophy */}
<section className="bg-sand">
<Editorial
caption={aboutPage.philosophy.caption}
captionLatin={aboutPage.philosophy.captionLatin}
title={aboutPage.philosophy.title}
body={aboutPage.philosophy.body}
/>
</section>
{/* Foundations */}
<section className="bg-paper">
<Editorial
caption={aboutPage.pillars.caption}
captionLatin={aboutPage.pillars.captionLatin}
title={aboutPage.pillars.title}
body={aboutPage.pillars.body}
/>
<Pillars />
</section>
{/* Portfolio */}
<section className="bg-sand">
<Editorial
caption={aboutPage.portfolio.caption}
captionLatin={aboutPage.portfolio.captionLatin}
title={aboutPage.portfolio.title}
body={aboutPage.portfolio.body}
/>
<ProjectGallery projects={displayProjects} />
</section>
{/* Closing statement */}
<section className="bg-paper">
<div className="mx-auto max-w-[1400px] px-6 py-24 text-center lg:px-12 lg:py-48">
<Reveal>
<p className="mx-auto max-w-[860px] text-[1.35rem] leading-[2.1] font-light text-ink lg:text-[1.75rem] lg:leading-[2]">
{aboutPage.closing.body}
</p>
</Reveal>
<Reveal delay={140}>
<span
aria-hidden="true"
className="mx-auto mt-14 block h-px w-24 bg-gold"
/>
</Reveal>
</div>
<FullBleed image={about} alt="نمای پروژه‌های گروه ساختمانی رئوفی" />
</section>
<PageCta
caption={aboutPage.cta.caption}
title={aboutPage.cta.title}
action={aboutPage.cta.action}
/>
</main>
<SiteFooter />
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import localFont from "next/font/local";
/**
* IranYekan — the Persian face used by raoofigroup.com.
* The "fanum" cuts render Latin digits with Persian numerals, which is what
* the original site relies on for phone numbers and dates.
*/
export const iranYekan = localFont({
src: [
{ path: "../assets/fonts/iranyekan-light.woff", weight: "300", style: "normal" },
{ path: "../assets/fonts/iranyekan-regular.woff", weight: "400", style: "normal" },
{ path: "../assets/fonts/iranyekan-bold.woff", weight: "700", style: "normal" },
{ path: "../assets/fonts/iranyekan-extrabold.woff", weight: "800", style: "normal" },
],
variable: "--font-iranyekan",
display: "swap",
preload: true,
});
/** Montserrat — used for the Latin project names and section kickers. */
export const montserrat = localFont({
src: [
{ path: "../assets/fonts/montserrat-light.ttf", weight: "300", style: "normal" },
{ path: "../assets/fonts/montserrat-regular.woff2", weight: "400", style: "normal" },
{ path: "../assets/fonts/montserrat-bold.ttf", weight: "700", style: "normal" },
],
variable: "--font-montserrat",
display: "swap",
});
+173
View File
@@ -0,0 +1,173 @@
@import "tailwindcss";
@theme {
/* ---- palette ------------------------------------------------------- */
--color-ink: #0e1013;
--color-ink-2: #16191d;
--color-ink-3: #22262b;
--color-paper: #fbfaf8;
--color-sand: #f2eee8;
--color-muted: #5c6873; /* brand body grey, carried over from the current site */
--color-gold: #b08d57;
--color-gold-soft: #cbab7d;
--color-line: #e2ddd5;
/* ---- type ---------------------------------------------------------- */
--font-sans: var(--font-iranyekan), system-ui, sans-serif;
--font-latin: var(--font-montserrat), system-ui, sans-serif;
/* ---- motion -------------------------------------------------------- */
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
--animate-fade-up: fade-up 0.9s var(--ease-out-soft) both;
--animate-slow-zoom: slow-zoom 9s var(--ease-out-soft) both;
--animate-marquee: marquee 38s linear infinite;
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(22px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes slow-zoom {
from {
transform: scale(1.12);
}
to {
transform: scale(1);
}
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(50%);
}
}
}
@layer base {
html {
scroll-behavior: smooth;
}
body {
background-color: var(--color-paper);
color: var(--color-muted);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4 {
color: var(--color-ink);
font-weight: 700;
line-height: 1.45;
}
::selection {
background-color: var(--color-gold);
color: #fff;
}
/* A restrained gold scrollbar keeps the luxury tone on desktop. */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--color-sand);
}
::-webkit-scrollbar-thumb {
background: var(--color-gold);
}
}
@layer components {
/* Latin lock-ups (project names, kickers) always read left-to-right. */
.latin {
font-family: var(--font-latin);
direction: ltr;
unicode-bidi: isolate;
}
/* Thin gold rule that precedes every section kicker. */
.kicker {
display: inline-flex;
align-items: center;
gap: 0.75rem;
color: var(--color-gold);
font-size: 0.75rem;
letter-spacing: 0.32em;
}
.kicker::before {
content: "";
width: 2.5rem;
height: 1px;
background: currentColor;
}
/* Rich text rendered from the portfolio API's mainTextHtml field. */
.cms-body {
max-width: 738px;
margin-inline: auto;
color: var(--color-muted);
font-size: 1rem;
line-height: 2.1;
}
.cms-body p {
margin-block: 1.25em;
}
.cms-body strong,
.cms-body b {
color: var(--color-ink);
font-weight: 700;
}
.cms-body a {
color: var(--color-gold);
text-decoration: underline;
text-underline-offset: 3px;
}
.cms-body a:hover {
color: var(--color-ink);
}
.cms-body img {
display: block;
width: 100%;
height: auto;
margin-block: 2em;
}
.cms-body h1,
.cms-body h2,
.cms-body h3 {
margin-block: 1.5em 0.75em;
color: var(--color-ink);
font-weight: 700;
}
}
@utility no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata, Viewport } from "next";
import { iranYekan, montserrat } from "./fonts";
import { site } from "@/data/site";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title: {
default: `${site.name} | صفحه اصلی`,
template: `%s | ${site.name}`,
},
description: site.description,
keywords: [
"گروه ساختمانی رئوفی",
"ساخت و ساز قم",
"برج الیزیوم",
"سرمایه گذاری ساختمان",
"طراحی و معماری",
],
openGraph: {
type: "website",
locale: "fa_IR",
siteName: site.name,
title: `${site.name} | ${site.tagline}`,
description: site.description,
},
robots: { index: true, follow: true },
};
export const viewport: Viewport = {
themeColor: "#0e1013",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable}`}>
<head>
{/* Scroll reveals are a JS enhancement — without it, show everything. */}
<noscript>
<style>{`[data-reveal]{opacity:1!important;transform:none!important}`}</style>
</noscript>
</head>
<body className="overflow-x-hidden antialiased">{children}</body>
</html>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { Metadata } from "next";
import atelier from "@/assets/images/about/atelier.jpg";
import servicesHero from "@/assets/images/services/services-hero.jpg";
import { ServiceGrid } from "@/components/services/ServiceGrid";
import { Editorial } from "@/components/shared/Editorial";
import { FullBleed } from "@/components/shared/FullBleed";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { ProjectGallery } from "@/components/shared/ProjectGallery";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { servicesPage } from "@/data/site";
import { getPortfolios, toDisplayProject } from "@/lib/api";
export const metadata: Metadata = {
title: "خدمات ما",
description:
"توسعه، سرمایه‌گذاری، طراحی و معماری، مدیریت ساخت و ساز، اجرا و نظارت و طراحی دکوراسیون؛ شش حوزه‌ی تخصصی گروه ساختمانی رئوفی.",
};
export default async function ServicesPage() {
const portfolios = await getPortfolios();
const displayProjects = portfolios.map(toDisplayProject);
return (
<>
<SiteHeader />
<main>
<PageHero
image={servicesHero}
caption={servicesPage.hero.caption}
title={servicesPage.hero.title}
breadcrumb={servicesPage.hero.breadcrumb}
/>
{/* Overview */}
<section className="bg-paper">
<Editorial
caption={servicesPage.overview.caption}
captionLatin={servicesPage.overview.captionLatin}
title={servicesPage.overview.title}
body={servicesPage.overview.body}
/>
<FullBleed image={atelier} alt="پروژه‌های گروه ساختمانی رئوفی" />
</section>
{/* The six disciplines */}
<section className="bg-sand">
<Editorial
caption={servicesPage.disciplines.caption}
captionLatin={servicesPage.disciplines.captionLatin}
title={servicesPage.disciplines.title}
body={servicesPage.disciplines.body}
/>
<ServiceGrid />
</section>
{/* Proof of work */}
<section className="bg-paper">
<Editorial
caption={servicesPage.portfolio.caption}
captionLatin={servicesPage.portfolio.captionLatin}
title={servicesPage.portfolio.title}
body={servicesPage.portfolio.body}
/>
<ProjectGallery projects={displayProjects} />
</section>
<PageCta
caption={servicesPage.cta.caption}
title={servicesPage.cta.title}
action={servicesPage.cta.action}
/>
</main>
<SiteFooter />
</>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { About } from "@/components/About";
import { ContactCta } from "@/components/ContactCta";
import { Hero } from "@/components/Hero";
import { Marquee } from "@/components/Marquee";
import { News } from "@/components/News";
import { Projects } from "@/components/Projects";
import { Services } from "@/components/Services";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { contact, site } from "@/data/site";
import { getPortfolios, toDisplayProject } from "@/lib/api";
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: site.name,
url: site.url,
slogan: site.tagline,
foundingDate: "2000",
address: {
"@type": "PostalAddress",
addressLocality: "قم",
addressCountry: "IR",
streetAddress: contact.address,
},
telephone: contact.phoneLinks,
email: contact.email,
sameAs: [contact.instagramUrl],
};
export default async function HomePage() {
const portfolios = await getPortfolios();
const displayProjects = portfolios.map(toDisplayProject);
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
/>
<SiteHeader />
<main>
<Hero />
<About />
<Marquee />
<Projects projects={displayProjects} />
<Services />
<News />
<ContactCta />
</main>
<SiteFooter />
</>
);
}
+164
View File
@@ -0,0 +1,164 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ProjectGalleryLightbox } from "@/components/portfolios/ProjectGalleryLightbox";
import { ProjectVideo } from "@/components/portfolios/ProjectVideo";
import { Reveal } from "@/components/Reveal";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { portfoliosPage, projectVideos } from "@/data/site";
import { getPortfolioBySlug, getPortfolios, toDisplayProject } from "@/lib/api";
export const revalidate = 3600;
export async function generateStaticParams() {
const portfolios = await getPortfolios();
return portfolios.map((item) => ({ slug: item.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const item = await getPortfolioBySlug(slug);
if (!item) return {};
return {
title: item.titleFa,
description: item.abstract || item.titleFa,
};
}
const publishedDateFormatter = new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
});
/**
* Structurally inspired by binghatti.com's project pages (hero, in-page jump
* nav, gallery grid, closing CTA) — but scoped strictly to what the meshkee
* API actually returns for a portfolio item. Binghatti's page also carries
* location/map, floor plans, a brochure download and priced unit listings;
* none of those exist as data here, so none of those sections are built.
*/
export default async function PortfolioDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const item = await getPortfolioBySlug(slug);
if (!item) notFound();
const project = toDisplayProject(item);
const hasGallery = project.gallery.length > 0;
const videoUrl = projectVideos[project.slug];
const publishedLabel = publishedDateFormatter.format(new Date(project.publishedAt));
const navItems = [
{ id: "overview", label: "معرفی پروژه" },
...(videoUrl ? [{ id: "video", label: "ویدیو" }] : []),
...(hasGallery ? [{ id: "gallery", label: "گالری تصاویر" }] : []),
];
// Backgrounds alternate by what actually renders, not by fixed section
// name — otherwise skipping the video section (no link set) could leave
// two sections the same colour back to back.
const videoBg = "bg-sand";
const galleryBg = videoUrl ? "bg-paper" : "bg-sand";
return (
<>
<SiteHeader />
<main>
<PageHero
image={project.image}
caption={project.type}
title={project.name}
breadcrumb={[
{ label: "صفحه اصلی", href: "/" },
{ label: "نمونه کارها", href: "/portfolios" },
{ label: project.name, href: `/portfolios/${project.slug}` },
]}
/>
{/* Jump nav — only rendered when there is a second section to jump to. */}
{navItems.length > 1 && (
<nav aria-label="بخش‌های صفحه" className="border-b border-line bg-paper">
<ul className="no-scrollbar mx-auto flex max-w-[1400px] items-center gap-8 overflow-x-auto px-6 lg:px-12">
{navItems.map((navItem) => (
<li key={navItem.id}>
<a
href={`#${navItem.id}`}
className="block scroll-mt-24 py-5 text-sm whitespace-nowrap text-muted transition-colors duration-300 hover:text-gold"
>
{navItem.label}
</a>
</li>
))}
</ul>
</nav>
)}
<section id="overview" className="scroll-mt-20 bg-paper py-16 lg:py-24">
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<div className="mx-auto mb-14 flex max-w-[738px] flex-col items-center gap-3 text-center">
<p className="kicker justify-center">معرفی پروژه</p>
<time dateTime={project.publishedAt} className="text-xs text-muted">
{publishedLabel}
</time>
</div>
</Reveal>
{project.bodyHtml && (
<Reveal delay={90}>
<div className="cms-body" dangerouslySetInnerHTML={{ __html: project.bodyHtml }} />
</Reveal>
)}
</div>
</section>
{videoUrl && (
<section id="video" className={`scroll-mt-20 ${videoBg} py-16 lg:py-24`}>
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<p className="kicker mb-14 justify-center">ویدیو</p>
</Reveal>
<Reveal delay={90}>
<ProjectVideo url={videoUrl} />
</Reveal>
</div>
</section>
)}
{hasGallery && (
<section id="gallery" className={`scroll-mt-20 ${galleryBg} py-16 lg:py-24`}>
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<p className="kicker mb-14 justify-center">گالری تصاویر</p>
</Reveal>
<ProjectGalleryLightbox images={project.gallery} alt={project.name} />
</div>
</section>
)}
<PageCta
caption={portfoliosPage.cta.caption}
title={portfoliosPage.cta.title}
action={portfoliosPage.cta.action}
/>
</main>
<SiteFooter />
</>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import heroImage from "@/assets/images/hero/hero-3.jpg";
import heroImageMobile from "@/assets/images/hero/hero-3-mobile.jpg";
import { PortfolioBrowser } from "@/components/portfolios/PortfolioBrowser";
import { Editorial } from "@/components/shared/Editorial";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { portfoliosPage } from "@/data/site";
import { deriveCategories, getPortfolios, toDisplayProject } from "@/lib/api";
export const metadata: Metadata = {
title: "نمونه کارها",
description: "نمونه‌کارهای منتشرشده‌ی گروه ساختمانی رئوفی، از پروژه‌های مسکونی تا برج‌های اداری-تجاری.",
};
export const revalidate = 3600;
export default async function PortfoliosPage() {
const portfolios = await getPortfolios();
const categories = deriveCategories(portfolios);
const displayProjects = portfolios.map(toDisplayProject);
return (
<>
<SiteHeader />
<main>
<PageHero
image={heroImage}
imageMobile={heroImageMobile}
caption={portfoliosPage.hero.caption}
title={portfoliosPage.hero.title}
breadcrumb={portfoliosPage.hero.breadcrumb}
/>
<section className="bg-paper">
<Editorial
caption={portfoliosPage.overview.caption}
captionLatin={portfoliosPage.overview.captionLatin}
title={portfoliosPage.overview.title}
body={portfoliosPage.overview.body}
compact
/>
<PortfolioBrowser projects={displayProjects} categories={categories} />
</section>
<PageCta
caption={portfoliosPage.cta.caption}
title={portfoliosPage.cta.title}
action={portfoliosPage.cta.action}
/>
</main>
<SiteFooter />
</>
);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

+11
View File
@@ -0,0 +1,11 @@
// next-env.d.ts already declares these, but keeping an explicit declaration here
// means `src/assets` type-checks even before the first `next dev` run generates it.
declare module "*.jpg" {
const content: import("next/image").StaticImageData;
export default content;
}
declare module "*.png" {
const content: import("next/image").StaticImageData;
export default content;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+1
View File
@@ -0,0 +1 @@
[{"name":"generate-buildid","duration":202,"timestamp":3438850805,"id":4,"parentId":1,"tags":{},"startTime":1785993924005,"traceId":"182d804ebf64083d"},{"name":"load-custom-routes","duration":289,"timestamp":3438851096,"id":5,"parentId":1,"tags":{},"startTime":1785993924005,"traceId":"182d804ebf64083d"},{"name":"next-build","duration":312095,"timestamp":3438543137,"id":1,"tags":{"buildMode":"default","isTurboBuild":"false","version":"15.5.4"},"startTime":1785993923697,"traceId":"182d804ebf64083d"}]
+112
View File
@@ -0,0 +1,112 @@
import Image from "next/image";
import Link from "next/link";
import aboutImage from "@/assets/images/about/about.jpg";
import { Reveal } from "@/components/Reveal";
import { about, site, stats } from "@/data/site";
export function About() {
return (
<section id="about" className="relative overflow-hidden bg-paper py-16 lg:py-28">
{/* Oversized watermark, mirrored to sit at the outer edge in RTL. */}
<span
aria-hidden="true"
className="latin pointer-events-none absolute -top-4 -left-8 hidden text-[11rem] leading-none font-bold text-ink/[0.035] select-none lg:block"
>
RAOOFI
</span>
<div className="mx-auto max-w-[1400px] px-5 lg:px-10">
<div className="grid items-center gap-14 lg:grid-cols-2 lg:gap-20">
<Reveal className="order-2 lg:order-1">
<div className="group relative">
<div className="relative aspect-[4/3] overflow-hidden">
<Image
src={aboutImage}
alt="نمای پروژه‌های گروه ساختمانی رئوفی"
fill
quality={88}
sizes="(max-width: 1024px) 100vw, 50vw"
placeholder="blur"
className="object-cover transition-transform duration-[1200ms] ease-out-soft group-hover:scale-105"
/>
</div>
{/* Gold frame offset behind the photo. */}
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-5 -left-5 -z-10 hidden h-full w-full border border-gold/45 lg:block"
/>
<div className="absolute -bottom-px right-0 bg-paper py-5 pe-0 ps-6">
<span className="latin text-xs tracking-[0.36em] text-gold">
{site.taglineLatin}
</span>
</div>
</div>
</Reveal>
<div className="order-1 lg:order-2">
<Reveal>
<p className="kicker mb-7">{about.kicker}</p>
</Reveal>
<Reveal delay={90}>
<h2 className="text-[1.625rem] leading-[1.3] font-extrabold whitespace-nowrap text-ink lg:text-[2.625rem]">
{about.titleTop} <span className="text-gold">{about.titleBottom}</span>
</h2>
</Reveal>
<Reveal delay={170}>
<p className="mt-8 border-e-2 border-gold pe-5 text-xl leading-relaxed font-light text-ink-2 lg:text-2xl">
{about.lead}
</p>
</Reveal>
<Reveal delay={240}>
<p className="mt-7 text-[1.02rem] leading-[2.15] text-muted">{about.body}</p>
</Reveal>
<Reveal delay={310}>
<p className="mt-7 text-lg leading-relaxed text-ink-2">{about.closing}</p>
</Reveal>
<Reveal delay={380}>
<Link
href="/aboutus"
className="group mt-10 inline-flex items-center gap-3 border-b border-gold pb-2 text-ink transition-colors duration-300 hover:text-gold"
>
{about.cta}
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="h-4 w-4 text-gold transition-transform duration-300 group-hover:-translate-x-1"
>
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.6" />
</svg>
</Link>
</Reveal>
</div>
</div>
{/* Key numbers */}
<div className="mt-24 grid grid-cols-2 gap-px border border-line bg-line lg:mt-32 lg:grid-cols-4">
{stats.map((stat, i) => (
<Reveal key={stat.label} delay={i * 90} className="bg-paper">
<div className="px-6 py-10 text-center lg:px-8 lg:py-12">
<span className="latin block text-[0.62rem] tracking-[0.34em] text-gold">
{stat.latin}
</span>
<span className="mt-4 block text-4xl font-extrabold text-ink lg:text-5xl">
{stat.value}
</span>
<span className="mt-3 block text-sm text-muted">{stat.label}</span>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}
+62
View File
@@ -0,0 +1,62 @@
import Image from "next/image";
import Link from "next/link";
import ctaImage from "@/assets/images/projects/elysium.jpg";
import { Reveal } from "@/components/Reveal";
import { contact } from "@/data/site";
export function ContactCta() {
return (
<section id="contact" className="relative isolate overflow-hidden py-20 lg:py-28">
<Image
src={ctaImage}
alt=""
fill
quality={72}
sizes="100vw"
placeholder="blur"
className="-z-20 object-cover object-top"
/>
<div className="absolute inset-0 -z-10 bg-ink/85" />
<div className="mx-auto max-w-[1400px] px-5 text-center lg:px-10">
<Reveal>
<p className="kicker mb-7 justify-center">
<span className="latin">GET IN TOUCH</span>
</p>
</Reveal>
<Reveal delay={90}>
<h2 className="mx-auto max-w-3xl text-3xl leading-[1.45] font-extrabold text-white lg:text-[3rem]">
بیایید فضای بعدیتان را
<span className="text-gold"> با هم بسازیم</span>
</h2>
</Reveal>
<Reveal delay={170}>
<p className="mx-auto mt-7 max-w-xl text-[1.02rem] leading-[2.1] text-white/70">
برای مشاوره سرمایهگذاری، همکاری در ساخت یا بازدید از پروژههای در حال اجرا، با
کارشناسان گروه رئوفی در تماس باشید.
</p>
</Reveal>
<Reveal delay={250}>
<div className="mt-11 flex flex-wrap items-center justify-center gap-4">
<Link
href={`tel:${contact.phoneLinks[0]}`}
className="rounded-full bg-gold px-9 py-3.5 text-white transition-colors duration-300 hover:bg-white hover:text-ink"
>
تماس با ما
</Link>
<Link
href={`mailto:${contact.email}`}
className="latin rounded-full border border-white/35 px-9 py-3.5 text-white transition-colors duration-300 hover:border-white hover:bg-white/10"
>
{contact.email}
</Link>
</div>
</Reveal>
</div>
</section>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { getImageProps } from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { about, heroSlides, site, type HeroSlide } from "@/data/site";
const SLIDE_MS = 6500;
/**
* Portrait and landscape crops ship separately, as on the source site.
*
* Rendering two <Image> elements and hiding one with CSS still downloads both,
* so the srcsets are handed to a <picture> instead — the browser resolves the
* media query before it fetches, and only one crop is ever requested.
*/
function SlideImage({
slide,
priority,
animate,
}: {
slide: HeroSlide;
priority: boolean;
animate: boolean;
}) {
const common = {
alt: "",
fill: true,
sizes: "100vw",
priority,
placeholder: "blur" as const,
};
const { props: desktop } = getImageProps({ ...common, src: slide.desktop, quality: 82 });
const { props: mobile } = getImageProps({ ...common, src: slide.mobile, quality: 72 });
return (
<picture>
<source media="(min-width: 768px)" srcSet={desktop.srcSet} sizes={desktop.sizes} />
<source media="(max-width: 767px)" srcSet={mobile.srcSet} sizes={mobile.sizes} />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
{...mobile}
alt=""
className={`object-cover ${animate ? "animate-slow-zoom" : ""}`}
/>
</picture>
);
}
export function Hero() {
const [active, setActive] = useState(0);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const startAutoplay = useCallback(() => {
if (timer.current) clearInterval(timer.current);
timer.current = setInterval(
() => setActive((i) => (i + 1) % heroSlides.length),
SLIDE_MS,
);
}, []);
useEffect(() => {
startAutoplay();
return () => {
if (timer.current) clearInterval(timer.current);
};
}, [startAutoplay]);
const goTo = (index: number) => {
setActive(index);
startAutoplay(); // restart the clock so a manual pick gets its full dwell time
};
return (
<section className="relative h-[100svh] min-h-[600px] w-full overflow-hidden bg-ink">
{heroSlides.map((slide, i) => (
<div
key={slide.id}
aria-hidden={i !== active}
className={`absolute inset-0 transition-opacity duration-[1400ms] ease-out-soft ${
i === active ? "opacity-100" : "opacity-0"
}`}
>
<SlideImage slide={slide} priority={i === 0} animate={i === active} />
</div>
))}
{/* Legibility scrim: heavier at the edges, light across the image itself. */}
<div className="absolute inset-0 bg-gradient-to-b from-black/65 via-black/25 to-black/80" />
<div className="absolute inset-0 bg-gradient-to-l from-black/45 to-transparent" />
<div className="relative z-10 mx-auto flex h-full max-w-[1400px] flex-col justify-center px-5 lg:px-10">
<div className="max-w-2xl">
<p className="kicker mb-7 animate-fade-up">
<span className="latin">SINCE 1379</span>
</p>
<h1
className="animate-fade-up text-[1.975rem] leading-[1.35] font-extrabold text-white sm:text-[3.125rem] lg:text-[3.625rem]"
style={{ animationDelay: "120ms" }}
>
{site.name}
</h1>
<p
className="mt-5 animate-fade-up text-xl font-light text-white/85 sm:text-2xl lg:text-3xl"
style={{ animationDelay: "260ms" }}
>
{site.tagline}
</p>
<div
className="mt-10 flex animate-fade-up flex-wrap items-center gap-4"
style={{ animationDelay: "400ms" }}
>
<Link
href="/aboutus"
className="group inline-flex items-center gap-3 rounded-full bg-gold px-8 py-3.5 text-white transition-colors duration-300 hover:bg-white hover:text-ink"
>
{about.cta}
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1"
>
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.6" />
</svg>
</Link>
<Link
href="/portfolios"
className="rounded-full border border-white/35 px-8 py-3.5 text-white transition-colors duration-300 hover:border-white hover:bg-white/10"
>
مشاهده پروژهها
</Link>
</div>
</div>
</div>
{/* Slide picker */}
<div className="absolute bottom-10 z-10 flex w-full justify-center gap-3 px-5 lg:bottom-12 lg:left-10 lg:w-auto lg:justify-start">
{heroSlides.map((slide, i) => (
<button
key={slide.id}
type="button"
onClick={() => goTo(i)}
aria-label={`اسلاید ${i + 1}`}
aria-current={i === active}
className="group h-8 py-3.5"
>
<span
className={`block h-px transition-all duration-500 ease-out-soft ${
i === active ? "w-14 bg-gold" : "w-7 bg-white/45 group-hover:bg-white"
}`}
/>
</button>
))}
</div>
<span className="latin absolute bottom-11 left-1/2 z-10 hidden -translate-x-1/2 text-[0.65rem] tracking-[0.42em] text-white/55 lg:block">
SCROLL
</span>
</section>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { site } from "@/data/site";
/** Continuous gold band separating the intro from the project work. */
export function Marquee() {
const items = Array.from({ length: 8 });
return (
<div className="overflow-hidden border-y border-line bg-sand py-6" aria-hidden="true">
{/* Rendered twice so the 50% translation loops seamlessly. */}
<div className="animate-marquee flex w-max items-center gap-14 whitespace-nowrap">
{items.concat(items).map((_, i) => (
<span key={i} className="flex items-center gap-14">
<span className="latin text-sm tracking-[0.42em] text-ink/45">
{site.taglineLatin}
</span>
<span className="h-1.5 w-1.5 rotate-45 bg-gold" />
</span>
))}
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Image from "next/image";
import Link from "next/link";
import { Reveal } from "@/components/Reveal";
import { news } from "@/data/site";
export function News() {
return (
<section id="news" className="bg-sand py-16 lg:py-24">
<div className="mx-auto max-w-[1400px] px-5 lg:px-10">
<div className="flex flex-wrap items-end justify-between gap-8">
<div>
<Reveal>
<p className="kicker mb-6">
<span className="latin">NEWS &amp; ARTICLES</span>
</p>
</Reveal>
<Reveal delay={90}>
<h2 className="text-[1.625rem] leading-[1.25] font-extrabold whitespace-nowrap text-ink lg:text-[2.625rem]">
جدیدترین <span className="text-gold">اخبار</span>
</h2>
</Reveal>
</div>
<Reveal delay={140}>
<Link
href="/news"
className="group flex items-center gap-3 border-b border-ink/20 pb-2 text-ink transition-colors duration-300 hover:border-gold hover:text-gold"
>
آرشیو مقالات و اخبار
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="h-4 w-4 text-gold transition-transform duration-300 group-hover:-translate-x-1"
>
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.6" />
</svg>
</Link>
</Reveal>
</div>
<ul className="mt-16 grid gap-8 md:grid-cols-3 lg:gap-10">
{news.map((item, i) => (
<Reveal as="li" key={item.slug} delay={i * 110}>
<Link href={`/news/${item.slug}`} className="group block h-full">
<article className="flex h-full flex-col bg-paper">
<div className="relative aspect-[16/11] overflow-hidden">
<Image
src={item.image}
alt={item.title}
fill
quality={80}
sizes="(max-width: 768px) 100vw, 33vw"
placeholder="blur"
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.07]"
/>
<div className="absolute inset-0 bg-ink/0 transition-colors duration-500 group-hover:bg-ink/20" />
</div>
<div className="flex flex-1 flex-col p-7 lg:p-8">
<time className="flex items-center gap-2.5 text-[0.8rem] text-muted">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 text-gold">
<rect x="3.5" y="5" width="17" height="15.5" rx="2" stroke="currentColor" strokeWidth="1.4" />
<path d="M3.5 10h17M8 3.5V6M16 3.5V6" stroke="currentColor" strokeWidth="1.4" />
</svg>
{item.date}
</time>
<h3 className="mt-4 text-[1.22rem] leading-[1.75] font-bold text-ink transition-colors duration-300 group-hover:text-gold">
{item.title}
</h3>
<span className="mt-auto flex items-center gap-2 pt-7 text-sm text-muted transition-colors duration-300 group-hover:text-gold">
ادامه مطلب
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
</svg>
</span>
</div>
</article>
</Link>
</Reveal>
))}
</ul>
</div>
</section>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { Reveal } from "@/components/Reveal";
import type { DisplayProject } from "@/lib/api";
export function Projects({ projects }: { projects: DisplayProject[] }) {
const trackRef = useRef<HTMLUListElement>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
const syncEdges = useCallback(() => {
const el = trackRef.current;
if (!el) return;
// The page is RTL, so scrollLeft runs from 0 down to -(scrollWidth - clientWidth).
const travelled = Math.abs(el.scrollLeft);
const max = el.scrollWidth - el.clientWidth;
setAtStart(travelled < 8);
setAtEnd(travelled >= max - 8);
}, []);
useEffect(() => {
syncEdges();
const el = trackRef.current;
if (!el) return;
el.addEventListener("scroll", syncEdges, { passive: true });
window.addEventListener("resize", syncEdges);
return () => {
el.removeEventListener("scroll", syncEdges);
window.removeEventListener("resize", syncEdges);
};
}, [syncEdges]);
const scrollByCard = (direction: "next" | "prev") => {
const el = trackRef.current;
if (!el) return;
const card = el.querySelector("li");
const step = card ? card.getBoundingClientRect().width + 24 : el.clientWidth * 0.8;
// In RTL, advancing means moving further into negative scrollLeft.
el.scrollBy({ left: direction === "next" ? -step : step, behavior: "smooth" });
};
return (
<section id="projects" className="bg-ink py-16 lg:py-24">
<div className="mx-auto max-w-[1400px] px-5 lg:px-10">
<div className="flex flex-wrap items-end justify-between gap-8">
<div>
<Reveal>
<p className="kicker mb-6">
<span className="latin">PORTFOLIO</span>
</p>
</Reveal>
<Reveal delay={90}>
<h2 className="text-[1.625rem] leading-[1.25] font-extrabold whitespace-nowrap text-white lg:text-[2.625rem]">
آخرین <span className="text-gold">پروژهها</span>
</h2>
</Reveal>
</div>
<div className="flex items-center gap-4">
<Link
href="/portfolios"
className="hidden border-b border-white/25 pb-2 text-sm text-white/75 transition-colors hover:border-gold hover:text-white sm:block"
>
مشاهده همه پروژهها
</Link>
<div className="flex gap-2">
<button
type="button"
onClick={() => scrollByCard("prev")}
disabled={atStart}
aria-label="پروژه‌های قبلی"
className="flex h-12 w-12 items-center justify-center rounded-full border border-white/25 text-white transition-colors duration-300 hover:border-gold hover:bg-gold disabled:pointer-events-none disabled:opacity-30"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M5 12h14m0 0-6-6m6 6-6 6" stroke="currentColor" strokeWidth="1.6" />
</svg>
</button>
<button
type="button"
onClick={() => scrollByCard("next")}
disabled={atEnd}
aria-label="پروژه‌های بعدی"
className="flex h-12 w-12 items-center justify-center rounded-full border border-white/25 text-white transition-colors duration-300 hover:border-gold hover:bg-gold disabled:pointer-events-none disabled:opacity-30"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.6" />
</svg>
</button>
</div>
</div>
</div>
</div>
{/* Track bleeds past the container so cards run off the viewport edge. */}
<ul
ref={trackRef}
className="no-scrollbar mt-14 flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth px-5 pb-2 lg:mt-16 lg:px-10"
>
{projects.map((project, i) => (
<li
key={project.slug}
className="w-[78vw] shrink-0 snap-start sm:w-[46vw] lg:w-[30vw] xl:w-[23rem]"
>
<Link href={`/portfolios/${project.slug}`} className="group block">
<div className="relative aspect-[3/4] overflow-hidden bg-ink-3">
<Image
src={project.image}
alt={`${project.type} ${project.name}`}
fill
quality={80}
sizes="(max-width: 640px) 78vw, (max-width: 1024px) 46vw, 23rem"
loading={i < 3 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.07]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/15 to-transparent" />
{/* Gold inset border on hover. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-4 border border-gold opacity-0 transition-opacity duration-500 group-hover:opacity-100"
/>
<div className="absolute inset-x-0 bottom-0 p-6">
<span className="latin block text-[0.68rem] tracking-[0.3em] text-gold-soft">
{project.nameLatin}
</span>
<h3 className="mt-2.5 text-2xl font-bold text-white">{project.name}</h3>
<span className="mt-1.5 block text-sm text-white/65">{project.type}</span>
<span className="mt-5 flex h-0 items-center gap-2 overflow-hidden text-sm text-white opacity-0 transition-all duration-500 ease-out-soft group-hover:h-6 group-hover:opacity-100">
مشاهده پروژه
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
</svg>
</span>
</div>
</div>
</Link>
</li>
))}
</ul>
<div className="mx-auto mt-10 max-w-[1400px] px-5 sm:hidden lg:px-10">
<Link
href="/portfolios"
className="block border border-white/25 py-3.5 text-center text-sm text-white transition-colors hover:border-gold hover:bg-gold"
>
مشاهده همه پروژهها
</Link>
</div>
</section>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useRef, useState } from "react";
type RevealProps = {
children: React.ReactNode;
/** Stagger, in milliseconds, applied once the element enters the viewport. */
delay?: number;
className?: string;
as?: "div" | "li" | "section" | "article";
};
/** Content must never stay hidden because an observer never fired. */
const SAFETY_MS = 2500;
/** Fades content up the first time it scrolls into view, then stops observing. */
export function Reveal({ children, delay = 0, className = "", as = "div" }: RevealProps) {
const ref = useRef<HTMLElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
// Browsers without IntersectionObserver get the content immediately.
if (typeof IntersectionObserver === "undefined") {
setShown(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShown(true);
observer.disconnect();
}
},
{ threshold: 0.14, rootMargin: "0px 0px -8% 0px" },
);
observer.observe(node);
// Backstop: if nothing has fired by now, reveal anyway rather than risk
// leaving a section permanently blank.
const safety = window.setTimeout(() => {
setShown(true);
observer.disconnect();
}, SAFETY_MS);
return () => {
observer.disconnect();
window.clearTimeout(safety);
};
}, []);
const Tag = as as React.ElementType;
return (
<Tag
ref={ref}
data-reveal=""
style={{ transitionDelay: `${delay}ms` }}
className={`transition-[opacity,transform] duration-[900ms] ease-out-soft ${
shown ? "translate-y-0 opacity-100" : "translate-y-8 opacity-0"
} ${className}`}
>
{children}
</Tag>
);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Line-art icons for the six service pillars.
*
* The current site references six PNG icons that now 404 on its CDN (they render
* as broken 0x0 images there), so these are drawn inline instead — no extra
* request, and they inherit the gold accent on hover.
*/
type IconProps = { className?: string };
const base = {
viewBox: "0 0 48 48",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.1,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
/** توسعه دهنده — tower blocks under development */
function Development({ className }: IconProps) {
return (
<svg {...base} className={className}>
<path d="M7 41h34" />
<path d="M12 41V19l11-6v28" />
<path d="M23 41V24l13 5v12" />
<path d="M16 24h3M16 30h3M16 36h3M27 31h4M27 36h4" />
<path d="M23 13V7" />
</svg>
);
}
/** سرمایه گذاری — value growth */
function Investment({ className }: IconProps) {
return (
<svg {...base} className={className}>
<path d="M7 40h34" />
<path d="M11 40V28M20 40V21M29 40V31M38 40V15" />
<path d="M10 20l9-7 8 5 12-10" />
<path d="M32 8h7v7" />
</svg>
);
}
/** طراحی و معماری — drafting compass over a plan */
function Architecture({ className }: IconProps) {
return (
<svg {...base} className={className}>
<circle cx="24" cy="10" r="3" />
<path d="M22 13 13 39M26 13l9 26" />
<path d="M18 28h12" />
<path d="M9 39h6M33 39h6" />
</svg>
);
}
/** مدیریت ساخت و ساز — safety helmet */
function Management({ className }: IconProps) {
return (
<svg {...base} className={className}>
<path d="M8 32a16 16 0 0 1 32 0" />
<path d="M5 32h38v4H5z" />
<path d="M19 17.5V10h10v7.5" />
<path d="M24 10v-.5" />
<path d="M14 36v4h20v-4" />
</svg>
);
}
/** اجرا و نظارت — verified checklist */
function Supervision({ className }: IconProps) {
return (
<svg {...base} className={className}>
<path d="M31 8h5a2 2 0 0 1 2 2v28a2 2 0 0 1-2 2H12a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h5" />
<path d="M18 6h12v5H18z" />
<path d="M16 22h9M16 29h9" />
<path d="M29 27.5l2.5 2.5 5-5" />
</svg>
);
}
/** طراحی و اجرا دکوراسیون — seating and floor lamp */
function Interior({ className }: IconProps) {
return (
<svg {...base} className={className}>
<path d="M6 40h36" />
<path d="M9 30v-4a3 3 0 0 1 6 0v4" />
<path d="M9 30h18v10H9z" />
<path d="M27 30v-4a3 3 0 0 0-6 0" />
<path d="M12 40v-3M24 40v-3" />
<path d="M36 40V21" />
<path d="M31 21h10l-3.5-8h-3z" />
</svg>
);
}
export const serviceIcons: Record<string, (props: IconProps) => React.ReactElement> = {
development: Development,
investment: Investment,
architecture: Architecture,
management: Management,
supervision: Supervision,
interior: Interior,
};
+82
View File
@@ -0,0 +1,82 @@
import Link from "next/link";
import { Reveal } from "@/components/Reveal";
import { serviceIcons } from "@/components/ServiceIcons";
import { services } from "@/data/site";
export function Services() {
return (
<section id="services" className="bg-paper py-16 lg:py-24">
<div className="mx-auto max-w-[1400px] px-5 lg:px-10">
<div className="flex flex-wrap items-end justify-between gap-8">
<div>
<Reveal>
<p className="kicker mb-6">
<span className="latin">WHAT WE DO</span>
</p>
</Reveal>
<Reveal delay={90}>
<h2 className="text-[1.625rem] leading-[1.25] font-extrabold whitespace-nowrap text-ink lg:text-[2.625rem]">
خدمات <span className="text-gold">ما</span>
</h2>
</Reveal>
</div>
<Reveal delay={140}>
<p className="max-w-md text-[1.02rem] leading-[2.1] text-muted">
از تأمین زمین و سرمایه تا طراحی، اجرا و تحویل؛ تمام مراحل یک پروژه زیر یک سقف
و با یک استاندارد واحد انجام میشود.
</p>
</Reveal>
</div>
<ul className="mt-16 grid gap-px border border-line bg-line sm:grid-cols-2 lg:grid-cols-3">
{services.map((service, i) => {
const Icon = serviceIcons[service.slug];
return (
<Reveal as="li" key={service.slug} delay={(i % 3) * 90} className="bg-paper">
<Link
href={`/our-services#${service.slug}`}
className="group relative block h-full overflow-hidden p-9 lg:p-11"
>
{/* Ink panel wipes upward on hover. */}
<span
aria-hidden="true"
className="absolute inset-0 origin-bottom scale-y-0 bg-ink transition-transform duration-[650ms] ease-out-soft group-hover:scale-y-100"
/>
<span
aria-hidden="true"
className="latin absolute top-7 left-8 text-sm text-ink/15 transition-colors duration-500 group-hover:text-white/25"
>
{String(i + 1).padStart(2, "0")}
</span>
<span className="relative block">
<Icon className="h-12 w-12 text-gold transition-transform duration-[650ms] ease-out-soft group-hover:-translate-y-1" />
<h3 className="mt-8 text-[1.35rem] font-bold text-ink transition-colors duration-500 group-hover:text-white">
{service.name}
</h3>
<span className="latin mt-3 block text-[0.68rem] tracking-[0.26em] text-muted transition-colors duration-500 group-hover:text-gold-soft">
{service.nameLatin}
</span>
<span className="mt-8 flex items-center gap-2 text-sm text-gold opacity-0 transition-opacity duration-500 group-hover:opacity-100">
جزئیات بیشتر
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
</svg>
</span>
</span>
</Link>
</Reveal>
);
})}
</ul>
</div>
</section>
);
}
+143
View File
@@ -0,0 +1,143 @@
import Image from "next/image";
import Link from "next/link";
import logo from "@/assets/images/brand/logo.png";
import { contact, nav, site } from "@/data/site";
function PinIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-gold">
<path
d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11Z"
stroke="currentColor"
strokeWidth="1.4"
/>
<circle cx="12" cy="10" r="2.5" stroke="currentColor" strokeWidth="1.4" />
</svg>
);
}
function PhoneIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4 shrink-0 text-gold">
<path
d="M6.5 3.5h3l1.5 4-2 1.5a12 12 0 0 0 6 6l1.5-2 4 1.5v3a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.5 5.7a2 2 0 0 1 2-2.2Z"
stroke="currentColor"
strokeWidth="1.4"
/>
</svg>
);
}
function MailIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4 shrink-0 text-gold">
<rect x="3" y="5.5" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.4" />
<path d="m3.5 7 8.5 6 8.5-6" stroke="currentColor" strokeWidth="1.4" />
</svg>
);
}
function InstagramIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4 shrink-0 text-gold">
<rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" strokeWidth="1.4" />
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.4" />
<circle cx="17" cy="7" r="1.1" fill="currentColor" />
</svg>
);
}
export function SiteFooter() {
return (
<footer className="bg-ink-2 pt-20 text-white/70">
<div className="mx-auto max-w-[1400px] px-5 lg:px-10">
<div className="grid gap-14 border-b border-white/10 pb-16 md:grid-cols-2 lg:grid-cols-4 lg:gap-10">
{/* Brand */}
<div className="lg:col-span-1">
<Image src={logo} alt={site.name} className="h-9 w-auto brightness-0 invert" />
<p className="mt-7 text-sm leading-[2.1]">
{site.tagline} از سال ۱۳۷۹ در حوزه توسعه، سرمایهگذاری و اجرای پروژههای
مسکونی، اداری و تجاری.
</p>
<Link
href={contact.instagramUrl}
className="mt-7 inline-flex h-11 w-11 items-center justify-center rounded-full border border-white/20 transition-colors duration-300 hover:border-gold hover:bg-gold"
aria-label={`اینستاگرام ${site.name}`}
>
<InstagramIcon />
</Link>
</div>
{/* Address */}
<div>
<h2 className="mb-7 text-base font-bold text-white">آدرس ما</h2>
<p className="flex gap-3 text-sm leading-[2.1]">
<PinIcon />
{contact.address}
</p>
</div>
{/* Pages */}
<div>
<h2 className="mb-7 text-base font-bold text-white">صفحات</h2>
<ul className="space-y-3.5">
{nav.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="group inline-flex items-center gap-2.5 text-sm transition-colors duration-300 hover:text-white"
>
<span className="h-1 w-1 rotate-45 bg-gold transition-transform duration-300 group-hover:scale-150" />
{item.label}
</Link>
</li>
))}
</ul>
</div>
{/* Contact */}
<div>
<h2 className="mb-7 text-base font-bold text-white">روش های ارتباطی</h2>
<ul className="space-y-4">
{contact.phones.map((phone, i) => (
<li key={phone}>
<Link
href={`tel:${contact.phoneLinks[i]}`}
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
>
<PhoneIcon />
{phone}
</Link>
</li>
))}
<li>
<Link
href={contact.instagramUrl}
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
>
<InstagramIcon />
<span className="latin">{contact.instagram}</span>
</Link>
</li>
<li>
<Link
href={`mailto:${contact.email}`}
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
>
<MailIcon />
<span className="latin">{contact.email}</span>
</Link>
</li>
</ul>
</div>
</div>
<div className="flex flex-col items-center justify-between gap-4 py-8 text-center text-xs leading-relaxed md:flex-row md:text-start">
<p>تمامی حقوق مادی و معنوی این سایت متعلق به سایت رئوفی است.</p>
<p>طراحی و توسعه توسط تیم تجارت الکترونیک مشکی</p>
</div>
</div>
</footer>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import logo from "@/assets/images/brand/logo.png";
import { contact, nav, site } from "@/data/site";
export function SiteHeader() {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// The drawer covers the viewport, so the page beneath it must not scroll.
useEffect(() => {
document.body.style.overflow = menuOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [menuOpen]);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-[background-color,backdrop-filter,box-shadow,padding] duration-500 ${
scrolled || menuOpen
? "bg-ink/92 py-3 shadow-[0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-xl"
: "bg-gradient-to-b from-black/55 to-transparent py-6"
}`}
>
<div className="mx-auto flex max-w-[1400px] items-center justify-between gap-6 px-5 lg:px-10">
<Link href="/" aria-label={site.name} className="shrink-0">
<Image
src={logo}
alt={site.name}
priority
className="h-8 w-auto brightness-0 invert lg:h-9"
/>
</Link>
<nav aria-label="فهرست اصلی" className="hidden lg:block">
<ul className="flex items-center gap-9">
{nav.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="group relative block py-1 text-[0.94rem] text-white/85 transition-colors hover:text-white"
>
{item.label}
<span className="absolute inset-x-0 -bottom-0.5 h-px origin-right scale-x-0 bg-gold transition-transform duration-400 ease-out-soft group-hover:scale-x-100" />
</Link>
</li>
))}
</ul>
</nav>
<div className="flex items-center gap-3">
<Link
href={contact.loginUrl}
className="hidden rounded-full border border-white/30 px-6 py-2 text-sm text-white transition-colors duration-300 hover:border-gold hover:bg-gold lg:inline-block"
>
ورود
</Link>
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
aria-label={menuOpen ? "بستن فهرست" : "باز کردن فهرست"}
className="flex h-11 w-11 flex-col items-center justify-center gap-[5px] lg:hidden"
>
<span
className={`block h-px w-6 bg-white transition-transform duration-300 ${
menuOpen ? "translate-y-[6px] rotate-45" : ""
}`}
/>
<span
className={`block h-px w-6 bg-white transition-opacity duration-300 ${
menuOpen ? "opacity-0" : ""
}`}
/>
<span
className={`block h-px w-6 bg-white transition-transform duration-300 ${
menuOpen ? "-translate-y-[6px] -rotate-45" : ""
}`}
/>
</button>
</div>
</div>
{/* Mobile drawer */}
<div
id="mobile-menu"
className={`overflow-hidden bg-ink transition-[max-height,opacity] duration-500 ease-out-soft lg:hidden ${
menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0"
}`}
>
<ul className="space-y-1 px-5 pt-6 pb-8">
{nav.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setMenuOpen(false)}
className="block border-b border-white/10 py-3.5 text-white/85 transition-colors hover:text-gold"
>
{item.label}
</Link>
</li>
))}
<li className="pt-5">
<Link
href={contact.loginUrl}
className="block rounded-full bg-gold py-3 text-center text-white"
>
ورود
</Link>
</li>
</ul>
</div>
</header>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Image from "next/image";
import { Reveal } from "@/components/Reveal";
import { aboutPage } from "@/data/site";
/** Three square plates, one per foundation named in the source text. */
export function Pillars() {
return (
<ul className="mx-auto grid max-w-[1400px] gap-6 px-6 pb-24 sm:grid-cols-3 lg:gap-8 lg:px-12 lg:pb-32">
{aboutPage.pillars.items.map((item, i) => (
<Reveal as="li" key={item.title} delay={i * 110}>
<figure className="group">
<div className="relative aspect-square overflow-hidden bg-ink">
<Image
src={item.image}
alt=""
fill
quality={80}
sizes="(max-width: 640px) 100vw, 33vw"
placeholder="blur"
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-105"
/>
<div className="absolute inset-0 bg-ink/15 transition-colors duration-500 group-hover:bg-ink/0" />
</div>
<figcaption className="pt-6 text-center">
<h3 className="text-xl font-light text-ink">{item.title}</h3>
<span className="latin mt-2.5 block text-[0.66rem] tracking-[0.26em] text-gold">
{item.latin}
</span>
</figcaption>
</figure>
</Reveal>
))}
</ul>
);
}
@@ -0,0 +1,131 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Reveal } from "@/components/Reveal";
import type { DisplayProject, PortfolioCategory } from "@/lib/api";
const ALL_ID = "__all__";
/**
* Category tabs above a grid — no search, click a tab to filter in place.
*
* Tabs only render when there is more than one real category on the fetched
* projects. The client's portfolio items currently share a single category,
* so the bar stays hidden rather than show one meaningless tab; the moment
* a second category exists in the CMS, this switches on with no code change.
*/
export function PortfolioBrowser({
projects,
categories,
}: {
projects: DisplayProject[];
categories: PortfolioCategory[];
}) {
const [activeId, setActiveId] = useState(ALL_ID);
const showTabs = categories.length > 1;
const filtered = useMemo(
() => (activeId === ALL_ID ? projects : projects.filter((p) => p.categoryId === activeId)),
[projects, activeId],
);
return (
<div>
{showTabs && (
<div className="no-scrollbar mx-auto mb-14 max-w-[1400px] overflow-x-auto px-6 lg:px-12">
<ul className="flex w-max items-center gap-10 border-b border-line">
<li>
<button
type="button"
onClick={() => setActiveId(ALL_ID)}
aria-current={activeId === ALL_ID}
className={`relative whitespace-nowrap pb-4 text-sm transition-colors duration-300 ${
activeId === ALL_ID ? "text-ink" : "text-muted hover:text-ink"
}`}
>
همه پروژهها
<span
aria-hidden="true"
className={`absolute inset-x-0 -bottom-px h-[2px] bg-gold transition-transform duration-300 ease-out-soft ${
activeId === ALL_ID ? "scale-x-100" : "scale-x-0"
}`}
/>
</button>
</li>
{categories.map((cat) => (
<li key={cat.id}>
<button
type="button"
onClick={() => setActiveId(cat.id)}
aria-current={activeId === cat.id}
className={`relative whitespace-nowrap pb-4 text-sm transition-colors duration-300 ${
activeId === cat.id ? "text-ink" : "text-muted hover:text-ink"
}`}
>
{cat.name}
<span
aria-hidden="true"
className={`absolute inset-x-0 -bottom-px h-[2px] bg-gold transition-transform duration-300 ease-out-soft ${
activeId === cat.id ? "scale-x-100" : "scale-x-0"
}`}
/>
</button>
</li>
))}
</ul>
</div>
)}
{filtered.length === 0 ? (
<p className="mx-auto max-w-[1400px] px-6 py-16 text-center text-muted lg:px-12">
پروژهای در این دسته یافت نشد.
</p>
) : (
<ul className="mx-auto grid max-w-[1400px] gap-6 px-6 pb-24 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:px-12 lg:pb-32">
{filtered.map((project, i) => (
<Reveal as="li" key={project.slug} delay={(i % 6) * 70}>
<Link href={`/portfolios/${project.slug}`} className="group block">
<div className="relative aspect-[3/4] overflow-hidden bg-ink">
<Image
src={project.image}
alt={`${project.type} ${project.name}`}
fill
quality={80}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
loading={i < 3 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.06]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/15 to-transparent" />
<div
aria-hidden="true"
className="pointer-events-none absolute inset-4 border border-gold opacity-0 transition-opacity duration-500 group-hover:opacity-100"
/>
<div className="absolute inset-x-0 bottom-0 p-6">
<span className="latin block text-[0.66rem] tracking-[0.28em] text-gold-soft">
{project.nameLatin}
</span>
<h3 className="mt-2.5 text-xl font-bold text-white">{project.name}</h3>
<span className="mt-1.5 block text-sm text-white/65">{project.type}</span>
<span className="mt-4 flex h-0 items-center gap-2 overflow-hidden text-sm text-white opacity-0 transition-all duration-500 ease-out-soft group-hover:h-6 group-hover:opacity-100">
مشاهده پروژه
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
</svg>
</span>
</div>
</div>
</Link>
</Reveal>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,154 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { Reveal } from "@/components/Reveal";
function ChevronIcon({ direction, className }: { direction: "next" | "prev"; className?: string }) {
const d = direction === "next" ? "M19 12H5m0 0 6-6m-6 6 6 6" : "M5 12h14m0 0-6-6m6 6-6 6";
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d={d} stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
/**
* The grid stays exactly as before; clicking a thumbnail opens it full-size
* with prev/next controls — a lightbox/slideshow over the same image list.
*/
export function ProjectGalleryLightbox({ images, alt }: { images: string[]; alt: string }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const close = useCallback(() => setOpenIndex(null), []);
const showNext = useCallback(
() => setOpenIndex((i) => (i === null ? null : (i + 1) % images.length)),
[images.length],
);
const showPrev = useCallback(
() => setOpenIndex((i) => (i === null ? null : (i - 1 + images.length) % images.length)),
[images.length],
);
useEffect(() => {
if (openIndex === null) return;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
if (e.key === "ArrowRight") showPrev(); // RTL: right arrow moves to the previous item
if (e.key === "ArrowLeft") showNext();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [openIndex, close, showNext, showPrev]);
return (
<>
<ul className="grid gap-6 sm:grid-cols-2 lg:gap-8">
{images.map((url, i) => (
<Reveal as="li" key={url} delay={(i % 4) * 80}>
<button
type="button"
onClick={() => setOpenIndex(i)}
aria-label={`مشاهده بزرگ‌تر تصویر ${i + 1}`}
className="group relative block aspect-[4/3] w-full overflow-hidden bg-ink"
>
<Image
src={url}
alt={`${alt} ${i + 1}`}
fill
quality={82}
sizes="(max-width: 1024px) 100vw, 50vw"
loading={i < 2 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.04]"
/>
<div className="absolute inset-0 flex items-center justify-center bg-ink/0 opacity-0 transition-all duration-300 group-hover:bg-ink/30 group-hover:opacity-100">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-9 w-9 text-white">
<path
d="M9 3H4v5m11-5h5v5M9 21H4v-5m11 5h5v-5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</div>
</button>
</Reveal>
))}
</ul>
{openIndex !== null && (
<div
role="dialog"
aria-modal="true"
aria-label="نمایش بزرگ‌شده‌ی تصویر"
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 p-4"
onClick={close}
>
<button
type="button"
onClick={close}
aria-label="بستن"
className="absolute top-5 left-5 flex h-11 w-11 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-6 w-6">
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
<span className="latin absolute top-6 right-6 text-sm text-white/60">
{openIndex + 1} / {images.length}
</span>
{images.length > 1 && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showPrev();
}}
aria-label="تصویر قبلی"
className="absolute end-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:end-6"
>
<ChevronIcon direction="prev" className="h-6 w-6" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showNext();
}}
aria-label="تصویر بعدی"
className="absolute start-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:start-6"
>
<ChevronIcon direction="next" className="h-6 w-6" />
</button>
</>
)}
<div
className="relative h-full max-h-[80vh] w-full max-w-5xl"
onClick={(e) => e.stopPropagation()}
>
<Image
src={images[openIndex]}
alt={`${alt} ${openIndex + 1}`}
fill
priority
quality={90}
sizes="(max-width: 1024px) 100vw, 1024px"
className="object-contain"
/>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,29 @@
import { resolveVideoEmbed } from "@/lib/video";
/**
* Simplest possible video section: give it a link, it plays. Nothing else to
* configure. See `projectVideos` in `src/data/site.ts` for where the link
* per project goes.
*/
export function ProjectVideo({ url }: { url: string }) {
const embed = resolveVideoEmbed(url);
if (!embed) return null;
return (
<div className="relative aspect-video w-full overflow-hidden bg-ink">
{embed.kind === "iframe" ? (
<iframe
src={embed.src}
title="ویدیوی پروژه"
className="absolute inset-0 h-full w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
loading="lazy"
/>
) : (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video src={embed.src} controls playsInline className="absolute inset-0 h-full w-full" />
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import Image from "next/image";
import { Reveal } from "@/components/Reveal";
import { serviceIcons } from "@/components/ServiceIcons";
import { serviceDetails, services } from "@/data/site";
/**
* All six disciplines at once, in the 3x2 grid the live site uses — hairline
* cards, Persian title, tracked Latin subtitle and a short rule beneath.
*
* A carousel would hide half of them behind a scroll, which is the one thing
* the source page deliberately does not do.
*/
export function ServiceGrid() {
return (
<ul className="mx-auto grid max-w-[1400px] gap-6 px-6 pb-24 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:px-12 lg:pb-32">
{services.map((service, i) => {
const detail = serviceDetails[service.slug];
const Icon = serviceIcons[service.slug];
return (
<Reveal as="li" key={service.slug} delay={(i % 3) * 90}>
<article
id={service.slug}
className="group flex h-full scroll-mt-28 flex-col border border-line bg-paper transition-colors duration-500 hover:border-gold/50"
>
<div className="relative aspect-[4/3] overflow-hidden bg-ink">
<Image
src={detail.image}
alt=""
fill
quality={80}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
placeholder="blur"
loading={i < 3 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-105"
/>
<div className="absolute inset-0 bg-ink/25 transition-colors duration-500 group-hover:bg-ink/10" />
<span className="latin absolute top-5 right-5 text-sm text-white/70">
{String(i + 1).padStart(2, "0")}
</span>
<Icon className="absolute bottom-5 right-5 h-9 w-9 text-white" />
</div>
<div className="flex flex-1 flex-col p-7 lg:p-8">
<h3 className="text-xl font-light text-ink">{service.name}</h3>
<span className="latin mt-2.5 block text-[0.66rem] tracking-[0.26em] text-muted/70">
{service.nameLatin}
</span>
{/* The short rule that sits under each label on the live site. */}
<span
aria-hidden="true"
className="mt-5 block h-px w-14 bg-gold/50 transition-all duration-500 ease-out-soft group-hover:w-20 group-hover:bg-gold"
/>
<p className="mt-5 text-[0.95rem] leading-[2.1] text-muted">{detail.blurb}</p>
</div>
</article>
</Reveal>
);
})}
</ul>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Reveal } from "@/components/Reveal";
/**
* The centred editorial column that carries every section of this page:
* small tracked caption → large light headline → narrow measure of body text.
*
* The rhythm mirrors the reference layout — a 192px vertical band, a 927px
* cap on the headline and a 738px measure on the prose — so the page reads as
* a sequence of calm, symmetrical statements separated by full-bleed imagery.
*/
export function Editorial({
caption,
captionLatin,
title,
body,
tone = "light",
compact = false,
}: {
caption?: string;
captionLatin?: string;
title?: string;
body?: readonly string[];
tone?: "light" | "dark";
compact?: boolean;
}) {
const heading = tone === "dark" ? "text-white" : "text-ink";
const prose = tone === "dark" ? "text-white/65" : "text-muted";
return (
<div
className={`mx-auto flex max-w-[1400px] flex-col items-center gap-6 px-6 text-center lg:px-12 ${
compact ? "py-12 lg:py-16" : "py-16 lg:py-24"
}`}
>
{(caption || captionLatin) && (
<Reveal>
<p className="flex items-center justify-center gap-3 text-sm text-gold">
{caption}
{caption && captionLatin && (
<span aria-hidden="true" className="h-1 w-1 rotate-45 bg-gold/60" />
)}
{captionLatin && (
<span className="latin tracking-[0.1em]">{captionLatin}</span>
)}
</p>
</Reveal>
)}
{title && (
<Reveal delay={90}>
<h2
className={`max-w-[927px] text-[1.9rem] leading-[1.45] font-light sm:text-4xl lg:text-5xl lg:leading-[1.3] ${heading}`}
>
{title}
</h2>
</Reveal>
)}
{body?.map((paragraph, i) => (
<Reveal key={i} delay={170 + i * 80}>
<p className={`max-w-[738px] text-base leading-[2.1] ${prose}`}>{paragraph}</p>
</Reveal>
))}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import Image, { type StaticImageData } from "next/image";
import { Reveal } from "@/components/Reveal";
/**
* Edge-to-edge 16:9 plate used to punctuate the editorial columns, matching
* the reference layout's full-width imagery between text blocks.
*/
export function FullBleed({
image,
alt,
priority = false,
}: {
image: StaticImageData;
alt: string;
priority?: boolean;
}) {
return (
<Reveal>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-sand sm:aspect-[16/9]">
<Image
src={image}
alt={alt}
fill
quality={82}
sizes="100vw"
placeholder="blur"
priority={priority}
className="object-cover"
/>
</div>
</Reveal>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Image from "next/image";
import Link from "next/link";
import ctaImage from "@/assets/images/projects/elysium.jpg";
import { Reveal } from "@/components/Reveal";
import { contact, site } from "@/data/site";
/** Dark closing band over a full-bleed plate, as on the reference page. */
export function PageCta({
caption,
title,
action,
}: {
caption: string;
title: string;
action: string;
}) {
return (
<section className="relative isolate flex min-h-[560px] items-center overflow-hidden py-28 lg:py-36">
<Image
src={ctaImage}
alt=""
fill
quality={72}
sizes="100vw"
placeholder="blur"
className="-z-20 object-cover object-top"
/>
<div className="absolute inset-0 -z-10 bg-ink/80" />
<div className="mx-auto w-full max-w-[1400px] px-6 text-center lg:px-12">
<Reveal>
<p className="latin text-xs tracking-[0.4em] text-gold">{caption}</p>
</Reveal>
<Reveal delay={90}>
<h2 className="mt-7 text-3xl font-light text-white lg:text-5xl">{title}</h2>
</Reveal>
<Reveal delay={180}>
<div className="mt-11 flex flex-wrap items-center justify-center gap-4">
<Link
href={`tel:${contact.phoneLinks[0]}`}
className="rounded-full bg-gold px-9 py-3.5 text-white transition-colors duration-300 hover:bg-white hover:text-ink"
>
{action}
</Link>
<Link
href="/contactus"
className="rounded-full border border-white/35 px-9 py-3.5 text-white transition-colors duration-300 hover:border-white hover:bg-white/10"
>
تماس با ما
</Link>
</div>
</Reveal>
<Reveal delay={260}>
<p className="latin mt-16 text-xs tracking-[0.42em] text-white/45">
{site.taglineLatin}
</p>
</Reveal>
</div>
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { getImageProps, type StaticImageData } from "next/image";
import Link from "next/link";
type Crumb = { readonly label: string; readonly href: string };
/** A bundled static asset, or a remote URL fetched at request time. */
type ImageSource = StaticImageData | string;
function isStaticImage(src: ImageSource): src is StaticImageData {
return typeof src !== "string";
}
/**
* Full-bleed opening plate shared by the inner pages: tracked Latin kicker,
* light Persian title, breadcrumb — set over a darkened image.
*/
export function PageHero({
image,
imageMobile,
caption,
title,
breadcrumb,
}: {
image: ImageSource;
/** Portrait crop, when one exists. Falls back to the landscape plate. */
imageMobile?: ImageSource;
caption: string;
title: string;
breadcrumb: readonly Crumb[];
}) {
const common = { alt: "", fill: true, sizes: "100vw", priority: true } as const;
const mobileSrc = imageMobile ?? image;
// Static imports carry a baked-in blur thumbnail; remote API images don't,
// so only bundled assets get the blur-up placeholder.
const { props: desktop } = getImageProps({
...common,
src: image,
quality: 82,
...(isStaticImage(image) ? { placeholder: "blur" as const } : {}),
});
const { props: mobile } = getImageProps({
...common,
src: mobileSrc,
quality: 72,
...(isStaticImage(mobileSrc) ? { placeholder: "blur" as const } : {}),
});
return (
<section className="relative flex h-[86svh] min-h-[520px] items-end overflow-hidden bg-ink">
<picture>
<source media="(min-width: 768px)" srcSet={desktop.srcSet} sizes={desktop.sizes} />
<source media="(max-width: 767px)" srcSet={mobile.srcSet} sizes={mobile.sizes} />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img {...mobile} alt="" className="animate-slow-zoom object-cover" />
</picture>
<div className="absolute inset-0 bg-gradient-to-b from-black/55 via-black/25 to-black/75" />
<div className="relative z-10 mx-auto w-full max-w-[1400px] px-6 pb-20 text-center lg:px-12 lg:pb-28">
<p className="latin animate-fade-up text-xs tracking-[0.4em] text-gold">{caption}</p>
<h1
className="mt-6 animate-fade-up text-4xl font-light text-white sm:text-5xl lg:text-6xl"
style={{ animationDelay: "140ms" }}
>
{title}
</h1>
<nav
aria-label="مسیر صفحه"
className="mt-8 animate-fade-up"
style={{ animationDelay: "280ms" }}
>
<ol className="flex items-center justify-center gap-3 text-sm text-white/60">
{breadcrumb.map((crumb, i, all) => (
<li key={crumb.href} className="flex items-center gap-3">
{i === all.length - 1 ? (
<span aria-current="page" className="text-white">
{crumb.label}
</span>
) : (
<Link href={crumb.href} className="transition-colors hover:text-white">
{crumb.label}
</Link>
)}
{i < all.length - 1 && (
<span aria-hidden="true" className="h-1 w-1 rotate-45 bg-gold" />
)}
</li>
))}
</ol>
</nav>
</div>
</section>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import type { DisplayProject } from "@/lib/api";
/**
* Wide 5:4 plates on a snapping rail, echoing the reference page's portfolio
* carousel. Dots below, arrows to the side — both RTL-aware.
*/
export function ProjectGallery({ projects }: { projects: DisplayProject[] }) {
const trackRef = useRef<HTMLUListElement>(null);
const [index, setIndex] = useState(0);
const syncIndex = useCallback(() => {
const el = trackRef.current;
if (!el) return;
const card = el.querySelector("li");
if (!card) return;
const step = card.getBoundingClientRect().width + 24;
// RTL: scrollLeft runs negative as the rail advances.
setIndex(Math.round(Math.abs(el.scrollLeft) / step));
}, []);
useEffect(() => {
const el = trackRef.current;
if (!el) return;
el.addEventListener("scroll", syncIndex, { passive: true });
return () => el.removeEventListener("scroll", syncIndex);
}, [syncIndex]);
const step = (direction: "next" | "prev") => {
const el = trackRef.current;
if (!el) return;
const card = el.querySelector("li");
const amount = card ? card.getBoundingClientRect().width + 24 : el.clientWidth * 0.8;
el.scrollBy({ left: direction === "next" ? -amount : amount, behavior: "smooth" });
};
const goTo = (i: number) => {
const el = trackRef.current;
if (!el) return;
const card = el.querySelector("li");
if (!card) return;
const amount = card.getBoundingClientRect().width + 24;
el.scrollTo({ left: -amount * i, behavior: "smooth" });
};
if (projects.length === 0) return null;
return (
<div className="pb-24 lg:pb-32">
<ul
ref={trackRef}
className="no-scrollbar flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth px-6 lg:px-12"
>
{projects.map((project, i) => (
<li
key={project.slug}
className="w-[86vw] shrink-0 snap-center sm:w-[62vw] lg:w-[46vw] xl:w-[40rem]"
>
<Link href={`/portfolios/${project.slug}`} className="group block">
<figure>
<div className="relative aspect-[5/4] overflow-hidden bg-ink">
<Image
src={project.image}
alt={`${project.type} ${project.name}`}
fill
quality={80}
sizes="(max-width: 640px) 86vw, (max-width: 1024px) 62vw, 40rem"
loading={i < 2 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.04]"
/>
</div>
<figcaption className="flex items-baseline justify-between gap-4 pt-5">
<h3 className="text-lg font-light text-ink">{project.name}</h3>
<span className="latin text-[0.66rem] tracking-[0.26em] text-gold">
{project.nameLatin}
</span>
</figcaption>
</figure>
</Link>
</li>
))}
</ul>
<div className="mx-auto mt-12 flex max-w-[1400px] items-center justify-center gap-8 px-6 lg:px-12">
<button
type="button"
onClick={() => step("prev")}
aria-label="قبلی"
className="flex h-12 w-12 items-center justify-center rounded-full border border-ink/15 text-ink transition-colors duration-300 hover:border-gold hover:bg-gold hover:text-white"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M5 12h14m0 0-6-6m6 6-6 6" stroke="currentColor" strokeWidth="1.4" />
</svg>
</button>
<div className="flex items-center gap-2.5">
{projects.map((project, i) => (
<button
key={project.slug}
type="button"
onClick={() => goTo(i)}
aria-label={`پروژه ${project.name}`}
aria-current={i === index}
className={`h-1.5 rounded-full transition-all duration-500 ease-out-soft ${
i === index ? "w-7 bg-gold" : "w-1.5 bg-ink/20 hover:bg-ink/40"
}`}
/>
))}
</div>
<button
type="button"
onClick={() => step("next")}
aria-label="بعدی"
className="flex h-12 w-12 items-center justify-center rounded-full border border-ink/15 text-ink transition-colors duration-300 hover:border-gold hover:bg-gold hover:text-white"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.4" />
</svg>
</button>
</div>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import type { StaticImageData } from "next/image";
import heroDesktop1 from "@/assets/images/hero/hero-1.jpg";
import heroDesktop2 from "@/assets/images/hero/hero-2.jpg";
import heroDesktop3 from "@/assets/images/hero/hero-3.jpg";
import heroMobile1 from "@/assets/images/hero/hero-1-mobile.jpg";
import heroMobile2 from "@/assets/images/hero/hero-2-mobile.jpg";
import heroMobile3 from "@/assets/images/hero/hero-3-mobile.jpg";
import onyx from "@/assets/images/projects/onyx.jpg";
import elysium from "@/assets/images/projects/elysium.jpg";
import hayat from "@/assets/images/projects/hayat.jpg";
import pania from "@/assets/images/projects/pania.jpg";
import ghasr3 from "@/assets/images/projects/ghasr-3.jpg";
import goldenGhasr from "@/assets/images/projects/golden-ghasr.jpg";
import yearEnd from "@/assets/images/news/year-end-ceremony.jpg";
import elysiumOpening from "@/assets/images/news/elysium-opening.jpg";
import hayatFoundation from "@/assets/images/news/hayat-foundation.jpg";
export const site = {
name: "گروه ساختمانی رئوفی",
tagline: "سازنده رویاهای شما",
taglineLatin: "BUILDING YOUR DREAMS",
description:
"گروه ساختمانی رئوفی، سازنده رویاهای شما. از سال ۱۳۷۹ در حوزه توسعه، سرمایه‌گذاری، طراحی و اجرای پروژه‌های مسکونی، اداری و تجاری در قم.",
url: "https://www.raoofigroup.com",
} as const;
export const nav = [
{ label: "صفحه اصلی", href: "/" },
{ label: "خدمات ما", href: "/our-services" },
{ label: "پروژه ها", href: "/portfolios" },
{ label: "مقالات و اخبار", href: "/news" },
{ label: "درباره ما", href: "/aboutus" },
{ label: "تماس با ما", href: "/contactus" },
] as const;
export const contact = {
address: "قم، بلوار الغدیر، خیابان صفاشهر، خیابان جهاد دانشگاهی، برج الیزیوم",
phones: ["۰۲۵-۳۳۵۵۲۰۰۶", "۰۲۵-۳۳۵۵۲۰۰۷"],
phoneLinks: ["+982533552006", "+982533552007"],
email: "info@raoofigroup.com",
instagram: "@raoofigroup",
instagramUrl: "https://instagram.com/raoofigroup",
loginUrl: "https://app.raoofigroup.com/auth/login",
} as const;
export type HeroSlide = {
id: string;
desktop: StaticImageData;
mobile: StaticImageData;
};
export const heroSlides: HeroSlide[] = [
{ id: "slide-1", desktop: heroDesktop1, mobile: heroMobile1 },
{ id: "slide-2", desktop: heroDesktop2, mobile: heroMobile2 },
{ id: "slide-3", desktop: heroDesktop3, mobile: heroMobile3 },
];
export const about = {
kicker: "درباره رئوفی",
titleTop: "گروه ساختمانی",
titleBottom: "رئوفـــــی",
lead: "فضاهایی که فراتر از زمان می‌ایستند...",
body: "از سال ۱۳۷۹، رئوفی فضاهایی خلق می‌کند که آرامش، دقت و زیبایی در آن با هم معنا پیدا می‌کنند. در گروه رئوفی معماری تنها ساختن نیست؛ تجربه‌ای است از لوکس‌بودن و ماندگاری. هر پروژه دعوتی‌ست به جهانی که در آن، نور، سکوت و جزئیات هویت فضا را می‌سازند. برای کسانی که کیفیت را در حس فضا می‌شناسند، نه در نمایش آن.",
closing: "رئوفی؛ برند زندگی‌های ساخته شده برای ماندن...",
cta: "درباره ما بیشتر بدانید...",
} as const;
export const stats = [
{ value: "۱۳۷۹", label: "سال تأسیس", latin: "SINCE" },
{ value: "۱۵", label: "پروژه شاخص", latin: "PROJECTS" },
{ value: "۶", label: "حوزه تخصصی", latin: "DISCIPLINES" },
{ value: "۱", label: "استاندارد؛ بی‌نقص", latin: "STANDARD" },
] as const;
export type Service = {
slug: string;
name: string;
nameLatin: string;
};
export const services: Service[] = [
{ slug: "development", name: "توسعه دهنده", nameLatin: "REAL ESTATE DEVELOPMENT" },
{ slug: "investment", name: "سرمایه گذاری", nameLatin: "INVESTMENT" },
{ slug: "architecture", name: "طراحی و معماری", nameLatin: "DESIGN AND ARCHITECTURE" },
{ slug: "management", name: "مدیریت ساخت و ساز", nameLatin: "CONSTRUCTION MANAGEMENT" },
{ slug: "supervision", name: "اجرا و نظارت", nameLatin: "SUPERVISION" },
{ slug: "interior", name: "طراحی و اجرا دکوراسیون", nameLatin: "INTERIOR DESIGN" },
];
/* ---------------------------------------------------------------------------
* About page — copy taken verbatim from raoofigroup.com/aboutus
* ------------------------------------------------------------------------- */
export const aboutPage = {
hero: {
caption: "ABOUT US",
title: "درباره ما",
breadcrumb: [
{ label: "صفحه اصلی", href: "/" },
{ label: "درباره ما", href: "/aboutus" },
],
},
overview: {
caption: "درباره ما",
captionLatin: "OVERVIEW",
title: "خلق فضاهایی که فراتر از زمان می‌ایستند",
body: [
"از سال ۱۳۷۹، دیدگاه مهندس قاسم رئوفی‌منش مسیر گروه رئوفی را تعریف کرده است؛ دیدگاهی که هر پروژه را از یک ساختمان به تجربه‌ای اصیل و ماندگار از سبک زندگی ارتقاء می‌دهد.",
],
},
philosophy: {
caption: "فلسفه ما",
captionLatin: "PHILOSOPHY",
title: "برای کسانی که خود، معیار می‌سازند",
body: [
"ما در گروه ساختمانی رئوفی، مجموعه‌هایی برای جمع محدودی از مشتریان خاص می‌سازیم؛ کسانی که به‌جای پیروی از معیارها، خود معیارهای تازه‌ای را رقم می‌زنند.",
"این رویکرد یکپارچه، از ایده تا اجرا، باعث شده هر پروژه زبان و شخصیت متمایز خود را داشته باشد: معماری اصیل، جزئیات بی‌قید و شرط، و زیبایی‌ای که در گذر زمان استوار می‌ماند.",
],
},
/** The three foundations named in the source text's third paragraph. */
pillars: {
caption: "بنیان‌ها",
captionLatin: "FOUNDATIONS",
title: "نوآوری مستمر، انتخاب بهترین‌ها، مهندسی دقیق",
body: [
"این جهان‌بینی در سال‌ها فعالیت گروه رئوفی، در قالب آثاری شکل گرفته که بر پایه‌ی نوآوری مستمر، انتخاب بهترین‌ها و مهندسی دقیق ساخته شده‌اند. با ساختاری مشترک از اصالت، زیبایی و کیفیت پایدار.",
],
items: [
{ title: "نوآوری مستمر", latin: "CONTINUOUS INNOVATION", image: onyx },
{ title: "انتخاب بهترین‌ها", latin: "THE FINEST SELECTION", image: goldenGhasr },
{ title: "مهندسی دقیق", latin: "PRECISE ENGINEERING", image: hayat },
],
},
portfolio: {
caption: "آثار ما",
captionLatin: "PORTFOLIO",
title: "مسیری که در هر پروژه‌ی تازه ادامه می‌یابد",
body: [
"از برج آزادی بندرعباس و برج‌تجاری قصر طلایی تا برج‌مسکونی قصر هرمزگان و برج تجارت جمهوری و مجموعه‌ی گسترده‌ی قصر ۱ تا ۱۴ در استان قم، و نیز پروژه‌هایی چون قاب، پانیا، اقاقیا، اونیکس، آپادانا، آسمان، حیات و برج الیزیوم.",
],
},
closing: {
body: "آثاری که مسیر گروه رئوفی را روشن کرده؛ مسیری آرام، دقیق و رو به آینده؛ مسیری که در هر پروژه‌ی تازه به شکل پیشرفته‌تری ادامه می‌یابد.",
},
cta: {
caption: "DISCOVER RAOOFI",
title: "با ما تماس بگیرید...",
action: "درخواست تماس",
},
} as const;
/* ---------------------------------------------------------------------------
* Services page
*
* The live page (raoofigroup.com/our-services) carries only the six service
* names and the "با خدمات ما بیشتر آشنا شوید..." headline — there are no
* descriptions and every card links back to "/". The `blurb` lines below were
* written for this rebuild: each one describes what the discipline itself
* covers, so nothing is claimed about the company that its own site does not
* already say. Replace them with the client's own copy when it exists.
* ------------------------------------------------------------------------- */
export const servicesPage = {
hero: {
caption: "OUR SERVICES",
title: "خدمات ما",
breadcrumb: [
{ label: "صفحه اصلی", href: "/" },
{ label: "خدمات ما", href: "/our-services" },
],
},
overview: {
caption: "خدمات ما",
captionLatin: "SERVICES",
/** Headline as written on the live page. */
title: "با خدمات ما بیشتر آشنا شوید...",
body: [
"رویکرد گروه رئوفی یکپارچه است؛ از ایده تا اجرا. شش حوزه‌ی تخصصی زیر، مراحل کامل یک پروژه را پوشش می‌دهند تا هر پروژه از نخستین طرح تا تحویل نهایی، درون یک ساختار واحد پیش برود.",
],
},
disciplines: {
caption: "حوزه‌های تخصصی",
captionLatin: "DISCIPLINES",
title: "از نخستین ایده تا کلید تحویل",
body: [
"هر حوزه به‌تنهایی قابل ارائه است و در کنار هم، مسیر کامل یک پروژه را می‌سازد.",
],
},
portfolio: {
caption: "در عمل",
captionLatin: "IN PRACTICE",
title: "آنچه این خدمات ساخته‌اند",
body: [
"هر پروژه حاصل هم‌زمانی این شش حوزه است؛ از تعریف طرح توسعه تا جزئیات دکوراسیون داخلی.",
],
},
cta: {
caption: "START A PROJECT",
title: "پروژه‌ی خود را با ما آغاز کنید",
action: "درخواست مشاوره",
},
} as const;
/** Per-service imagery and supporting copy for the services page. */
export const serviceDetails: Record<
string,
{ image: StaticImageData; blurb: string }
> = {
development: {
image: elysium,
blurb:
"شناسایی و تأمین زمین، تعریف کاربری و شکل‌دهی به طرح توسعه؛ از نخستین مطالعه‌ی امکان‌سنجی تا تحویل پروژه‌ی تکمیل‌شده.",
},
investment: {
image: goldenGhasr,
blurb:
"ساختاردهی مشارکت و تأمین مالی پروژه، همراه با تحلیل بازار و مدیریت ریسک سرمایه در تمام دوره‌ی ساخت.",
},
architecture: {
image: onyx,
blurb:
"شکل‌گیری ایده‌ی معماری، طراحی نما و پلان، و رساندن طرح تا مرحله‌ی نقشه‌های اجرایی.",
},
management: {
image: hayat,
blurb:
"برنامه‌ریزی زمان‌بندی، هماهنگی پیمانکاران و کنترل هزینه؛ مدیریت یکپارچه‌ی تمام مراحل ساخت.",
},
supervision: {
image: ghasr3,
blurb:
"اجرای سازه و تأسیسات مطابق نقشه، همراه با نظارت مستمر بر کیفیت مصالح و جزئیات کار.",
},
interior: {
image: pania,
blurb:
"طراحی داخلی فضاها، انتخاب متریال و مبلمان، و اجرای کامل دکوراسیون تا تحویل نهایی.",
},
};
/* ---------------------------------------------------------------------------
* Portfolio listing page — the projects themselves are fetched live from the
* meshkee API (see src/lib/api.ts); this is just the surrounding page copy.
* ------------------------------------------------------------------------- */
export const portfoliosPage = {
hero: {
caption: "OUR PORTFOLIO",
title: "نمونه کارها",
breadcrumb: [
{ label: "صفحه اصلی", href: "/" },
{ label: "نمونه کارها", href: "/portfolios" },
],
},
overview: {
caption: "نمونه کارها",
captionLatin: "PORTFOLIO",
title: "پروژه‌هایی که مسیر رئوفی را روایت می‌کنند",
body: [
"هر پروژه، فصلی از مسیر گروه رئوفی است؛ از نخستین ساخت‌وسازها تا برج‌های امروز. در ادامه، نمونه‌کارهای منتشرشده را می‌بینید.",
],
},
cta: {
caption: "START A PROJECT",
title: "پروژه‌ی بعدی، پروژه‌ی شماست",
action: "درخواست مشاوره",
},
} as const;
/**
* Per-project video, keyed by portfolio slug. The meshkee API has no video
* field, so this is the one manual step: paste an Aparat, YouTube, or direct
* video-file link here and that project's video section turns on by itself.
*/
export const projectVideos: Record<string, string> = {
// Recovered from GHAB's ("ساختمان مسکونی قاب") CMS body — it originally
// pasted this same Aparat video as a broken <script> embed (the artifact
// shown in the bug report), which is now stripped out of the body text.
"item-old-589": "https://www.aparat.com/v/dnem1b3",
};
export type NewsItem = {
slug: string;
title: string;
date: string;
image: StaticImageData;
};
export const news: NewsItem[] = [
{
slug: "year-end-ceremony",
title: "مراسم پایان سال گروه رئوفی",
date: "شنبه ۲۲ شهریور ۱۴۰۴",
image: yearEnd,
},
{
slug: "elysium-opening",
title: "مراسم افتتاحیه برج الیزیوم",
date: "سه‌شنبه ۱۹ فروردین ۱۴۰۴",
image: elysiumOpening,
},
{
slug: "hayat-foundation",
title: "بتن ریزی فوندانسیون پروژه حیات",
date: "شنبه ۱۳ بهمن ۱۴۰۳",
image: hayatFoundation,
},
];
+167
View File
@@ -0,0 +1,167 @@
/**
* meshkee.com public site API — see https://api.meshkee.com/docs/website
*
* Tenants are resolved by apex domain (`raoofigroup.com`), then public data
* lives under `/tenants/{domain}/...` with no auth required.
*
* Note on categories: the tenant-level `/categories` endpoint exists but is
* unrelated to portfolios in practice (it returns an empty product-category
* list even though every portfolio item already carries its own categoryId/
* categoryName). Category tabs are therefore derived directly from the
* portfolio items themselves — the moment the client assigns real categories
* to projects in their CMS, the filter picks them up with no code change.
*/
const API_BASE = "https://api.meshkee.com/api/v1";
const SITE_DOMAIN = "raoofigroup.com";
/** How long fetched portfolio data is cached before Next.js revalidates it. */
const REVALIDATE_SECONDS = 3600;
/**
* api.meshkee.com has been observed to hang for minutes at a time rather than
* error out quickly. Without a bound, a single slow request can stall page
* generation for as long as the upstream host allows — a real, repeatable
* failure mode, not a hypothetical one. Failing fast here at least turns that
* into a clean error instead of a multi-minute hang.
*/
const REQUEST_TIMEOUT_MS = 10_000;
export type ApiPortfolioItem = {
id: string;
businessId: string;
title: string;
titleFa: string;
titleEn: string;
slug: string;
abstract: string;
mainTextHtml: string;
status: string;
categoryId: string;
categoryName: string;
tags: string[];
titleImageUrl: string | null;
featuredMediaId: string | null;
gallery: { mediaId: string; url: string }[];
galleryMediaIds: string[];
sortOrder: number;
publishedAt: string;
createdAt: string;
updatedAt: string;
};
type PortfoliosResponse = {
items: ApiPortfolioItem[];
total: number;
page: number;
pageSize: number;
};
async function apiGet<T>(path: string): Promise<T> {
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
headers: { Accept: "application/json" },
next: { revalidate: REVALIDATE_SECONDS },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (cause) {
throw new Error(`meshkee API ${path} timed out or was unreachable`, { cause });
}
if (!res.ok) {
throw new Error(`meshkee API ${path} responded ${res.status}`);
}
return res.json() as Promise<T>;
}
/** All published portfolio items, every page merged, in the CMS's own order. */
export async function getPortfolios(): Promise<ApiPortfolioItem[]> {
const first = await apiGet<PortfoliosResponse>(`/tenants/${SITE_DOMAIN}/portfolios`);
const pageCount = Math.ceil(first.total / first.pageSize);
const rest = await Promise.all(
Array.from({ length: Math.max(0, pageCount - 1) }, (_, i) =>
apiGet<PortfoliosResponse>(`/tenants/${SITE_DOMAIN}/portfolios?page=${i + 2}`),
),
);
return [first, ...rest]
.flatMap((page) => page.items)
.filter((item) => item.status === "published")
.sort((a, b) => a.sortOrder - b.sortOrder);
}
export async function getPortfolioBySlug(slug: string): Promise<ApiPortfolioItem | null> {
const items = await getPortfolios();
return items.find((item) => item.slug === slug) ?? null;
}
export type PortfolioCategory = { id: string; name: string; count: number };
/** Distinct categories actually present on published portfolio items. */
export function deriveCategories(items: ApiPortfolioItem[]): PortfolioCategory[] {
const byId = new Map<string, PortfolioCategory>();
for (const item of items) {
const existing = byId.get(item.categoryId);
if (existing) {
existing.count += 1;
} else {
byId.set(item.categoryId, { id: item.categoryId, name: item.categoryName, count: 1 });
}
}
return [...byId.values()];
}
/* ---------------------------------------------------------------------------
* Display adapter — the shape every gallery/card component in the UI expects,
* independent of the raw API response.
* ------------------------------------------------------------------------- */
export type DisplayProject = {
slug: string;
name: string;
nameLatin: string;
type: string;
categoryId: string;
image: string;
gallery: string[];
bodyHtml: string;
publishedAt: string;
};
const FALLBACK_IMAGE =
"https://meshkee-storage.sas.amin.parminstorage.ir/meshkee/businesses/9/media/9582b492-970a-4165-b893-7c36ff3d5b4f.jpg";
/**
* Some portfolio bodies carry a broken video-embed paragraph: whoever pasted
* an Aparat/iframe embed into the CMS's rich text editor ended up with the
* markup HTML-escaped (`&lt;script&gt;` instead of a real `<script>` tag), so
* it renders as visible garbled text instead of a video. React's
* dangerouslySetInnerHTML wouldn't execute a real <script> tag either way, so
* there's nothing worth recovering here — the paragraph is just dropped.
* (Real videos now go through ProjectVideo / projectVideos instead.)
*/
function stripBrokenEmbedArtifacts(html: string): string {
return html.replace(
/<p[^>]*>(?:(?!<\/p>)[\s\S])*&lt;(?:script|iframe)\b(?:(?!<\/p>)[\s\S])*<\/p>\s*/gi,
"",
);
}
export function toDisplayProject(item: ApiPortfolioItem): DisplayProject {
return {
slug: item.slug,
name: item.titleFa || item.title,
nameLatin: item.titleEn || item.titleFa || item.title,
type: item.categoryName,
categoryId: item.categoryId,
image: item.titleImageUrl ?? FALLBACK_IMAGE,
gallery: item.gallery.map((g) => g.url),
bodyHtml: stripBrokenEmbedArtifacts(item.mainTextHtml),
publishedAt: item.publishedAt,
};
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Turns a plain video link into something embeddable.
*
* The portfolio API has no video field, so a link is pasted manually (see
* `projectVideos` in `src/data/site.ts`) rather than fetched — this just
* figures out how to play whatever link gets pasted there: an Aparat page
* URL, a YouTube URL, or a direct video file.
*/
export type VideoEmbed =
| { kind: "iframe"; src: string }
| { kind: "file"; src: string };
function aparatEmbed(url: URL): VideoEmbed | null {
// Aparat page URLs look like aparat.com/v/HASH or aparat.com/video/video/.../HASH/...
const hash = url.pathname.split("/").filter(Boolean).pop();
if (!hash) return null;
return { kind: "iframe", src: `https://www.aparat.com/video/video/embed/videohash/${hash}/vt/frame` };
}
function youtubeEmbed(url: URL): VideoEmbed | null {
const id =
url.hostname.includes("youtu.be")
? url.pathname.slice(1)
: (url.searchParams.get("v") ?? url.pathname.split("/").pop());
if (!id) return null;
return { kind: "iframe", src: `https://www.youtube.com/embed/${id}` };
}
export function resolveVideoEmbed(link: string): VideoEmbed | null {
let url: URL;
try {
url = new URL(link);
} catch {
return null;
}
if (url.hostname.includes("aparat.com")) return aparatEmbed(url);
if (url.hostname.includes("youtube.com") || url.hostname.includes("youtu.be")) {
return youtubeEmbed(url);
}
// Anything else is treated as a direct video file link.
return { kind: "file", src: link };
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}