Redesign about page and stabilize site data
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "node scripts/dev.cjs",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 632 KiB |
@@ -0,0 +1,48 @@
|
||||
const { execFileSync, spawn } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
function supportsNext(nodePath) {
|
||||
try {
|
||||
const version = execFileSync(nodePath, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const [major, minor] = version.replace(/^v/, "").split(".").map(Number);
|
||||
return major >= 20 || (major === 18 && minor >= 18) || (major === 19 && minor >= 8);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function installedNodes() {
|
||||
const command = process.platform === "win32" ? "where.exe" : "which";
|
||||
const args = process.platform === "win32" ? ["node"] : ["-a", "node"];
|
||||
|
||||
try {
|
||||
return execFileSync(command, args, { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const nodePath = [process.execPath, ...installedNodes()].find(supportsNext);
|
||||
|
||||
if (!nodePath) {
|
||||
console.error("Next.js به Node.js 18.18 یا جدیدتر نیاز دارد. لطفاً Node.js LTS را نصب کنید.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const nextCli = require.resolve("next/dist/bin/next");
|
||||
const child = spawn(nodePath, [nextCli, "dev", ...process.argv.slice(2)], {
|
||||
cwd: path.resolve(__dirname, ".."),
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -1,115 +1,25 @@
|
||||
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 { OmniyatAbout } from "@/components/about/OmniyatAbout";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { aboutPage } from "@/data/site";
|
||||
import { getRecentPortfolios, toDisplayProject } from "@/lib/api";
|
||||
|
||||
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
||||
export const revalidate = 300;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "درباره ما",
|
||||
description:
|
||||
"از سال ۱۳۷۹، دیدگاه مهندس قاسم رئوفیمنش مسیر گروه رئوفی را تعریف کرده است؛ دیدگاهی که هر پروژه را به تجربهای اصیل و ماندگار از سبک زندگی ارتقاء میدهد.",
|
||||
description: "روایت شکلگیری، ارزشها و مسیر گروه ساختمانی رئوفی از سال ۱۳۷۹ تا امروز.",
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const portfolios = await getRecentPortfolios(12);
|
||||
const portfolios = await getRecentPortfolios(8);
|
||||
const displayProjects = portfolios.map(toDisplayProject);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
<main>
|
||||
<PageHero
|
||||
image={heroImage}
|
||||
imageMobile={heroImageMobile}
|
||||
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>
|
||||
|
||||
<OmniyatAbout projects={displayProjects} />
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,20 +5,11 @@ 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";
|
||||
import { 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 () => {
|
||||
@@ -27,103 +18,76 @@ export function SiteHeader() {
|
||||
}, [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">
|
||||
<>
|
||||
<header
|
||||
dir="rtl"
|
||||
className="fixed inset-x-0 top-0 z-[60] grid h-[68px] grid-cols-3 items-center bg-ink/65 px-5 text-white shadow-[0_1px_0_rgba(255,255,255,0.1)] backdrop-blur-xl lg:h-[84px] lg:px-[5%]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
aria-label="باز کردن فهرست"
|
||||
className="grid w-8 justify-self-start gap-[6px] py-2.5"
|
||||
>
|
||||
<span className="block h-px w-[23px] bg-current" />
|
||||
<span className="block h-px w-[23px] bg-current" />
|
||||
</button>
|
||||
|
||||
<Link href="/" aria-label={site.name} className="justify-self-center">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={site.name}
|
||||
priority
|
||||
className="h-8 w-auto brightness-0 invert lg:h-9"
|
||||
className="h-auto w-24 brightness-0 invert lg:w-[120px]"
|
||||
/>
|
||||
</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>
|
||||
<Link
|
||||
href="/contactus"
|
||||
className="min-w-[75px] justify-self-end border border-white/85 px-2 py-2 text-center text-[10px] text-white transition-colors hover:bg-white hover:text-ink lg:min-w-[110px] lg:px-5 lg:py-2.5 lg:text-[11px]"
|
||||
>
|
||||
تماس با ما
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<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"
|
||||
dir="rtl"
|
||||
aria-hidden={!menuOpen}
|
||||
className={`fixed inset-0 z-[100] flex flex-col bg-[#252522] px-[8vw] py-[6vh] text-white transition-[opacity,visibility,transform] duration-300 ${
|
||||
menuOpen
|
||||
? "visible translate-y-0 opacity-100"
|
||||
: "invisible -translate-y-4 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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-label="بستن فهرست"
|
||||
className="self-start text-[38px] leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<nav aria-label="فهرست اصلی" className="m-auto grid w-full max-w-[680px]">
|
||||
{nav.map((item, index) => (
|
||||
<Link
|
||||
href={contact.loginUrl}
|
||||
className="block rounded-full bg-gold py-3 text-center text-white"
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
tabIndex={menuOpen ? 0 : -1}
|
||||
className="flex items-center gap-[22px] border-b border-white/20 py-3 text-[clamp(18px,3vw,32px)] font-light text-white transition-colors hover:text-gold"
|
||||
>
|
||||
ورود
|
||||
<small className="font-sans text-[11px] text-white/50">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</small>
|
||||
{item.label}
|
||||
<span className="mr-auto text-xl">←</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<span className="text-xs text-white/60">هنرِ ساختنِ زندگی</span>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import type { Swiper as SwiperInstance } from "swiper";
|
||||
import "swiper/css";
|
||||
|
||||
import type { DisplayProject } from "@/lib/api";
|
||||
import "./omniyat-about.css";
|
||||
|
||||
const asset = (name: string) => `/omniyat-reference/about/${name}`;
|
||||
|
||||
const fallbackProjects = [
|
||||
{ slug: "", name: "فصل نخست یک رؤیا", type: "۱۳۷۹", image: asset("timeline-1.webp") },
|
||||
{ slug: "", name: "خلق یک نشانهٔ ماندگار", type: "معماری", image: asset("timeline-2.webp") },
|
||||
{ slug: "", name: "زندگی در میان هنر", type: "طراحی", image: asset("timeline-3.webp") },
|
||||
{ slug: "", name: "افق تازهٔ رئوفی", type: "امروز", image: asset("timeline-4.webp") },
|
||||
];
|
||||
|
||||
const pillars = [
|
||||
{ image: asset("pillar-1.webp"), title: "تعریف معیارهای تازه", text: "با نگاهی پیشرو، هر پروژه را به معیاری تازه برای معماری و کیفیت زندگی تبدیل میکنیم." },
|
||||
{ image: asset("pillar-2.webp"), title: "ساخت میراث", text: "آثاری خلق میکنیم که از زمان عبور کنند و بخشی از هویت شهر و خاطرهٔ نسلها شوند." },
|
||||
{ image: asset("pillar-3.webp"), title: "مهندسی شاهکارها", text: "مکان، طراحی، نوآوری و اجرای دقیق را کنار هم قرار میدهیم تا تجربهای یگانه شکل بگیرد." },
|
||||
{ image: asset("pillar-4.webp"), title: "جسارت رؤیاپردازی", text: "مرزهای ممکن را جابهجا میکنیم و ایدههای بلندپروازانه را با جزئیات بینقص به واقعیت میرسانیم." },
|
||||
];
|
||||
|
||||
export function OmniyatAbout({ projects: _projects }: { projects: DisplayProject[] }) {
|
||||
const timelineRef = useRef<SwiperInstance | null>(null);
|
||||
const pillarsRef = useRef<SwiperInstance | null>(null);
|
||||
const [timelineIndex, setTimelineIndex] = useState(0);
|
||||
const [pillarIndex, setPillarIndex] = useState(0);
|
||||
// This timeline follows the reference page's editorial sequence and uses
|
||||
// locally bundled assets so it never depends on an external image host.
|
||||
const timeline = fallbackProjects;
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => entries.forEach((entry) => entry.isIntersecting && entry.target.classList.add("is-visible")),
|
||||
{ threshold: 0.12 },
|
||||
);
|
||||
document.querySelectorAll(".oas-reveal").forEach((element) => observer.observe(element));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => timelineRef.current?.slideNext(), 6500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="oas-about" dir="rtl">
|
||||
<section className="oas-hero">
|
||||
<div className="oas-hero-media">
|
||||
<video autoPlay muted loop playsInline preload="metadata" poster="/omniyat-reference/hero.jpg">
|
||||
<source src={asset("hero-mobile.mp4")} media="(max-width: 700px)" type="video/mp4" />
|
||||
<source src={asset("hero-desktop.mp4")} type="video/mp4" />
|
||||
</video>
|
||||
<div className="oas-shade" />
|
||||
<div className="oas-hero-title">
|
||||
<h1>از چشمانداز تا واقعیت</h1>
|
||||
<h2>بیست و پنج سال در ساختِ ناممکن</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="oas-story">
|
||||
<div className="oas-copy oas-reveal">
|
||||
<span>داستان ما</span>
|
||||
<h2>هر اتفاق بزرگ<br />با یک رؤیا آغاز شد</h2>
|
||||
<div>
|
||||
<p>از سال ۱۳۷۹، گروه ساختمانی رئوفی با یک نگاه روشن شکل گرفت: تصور آنچه دیگران ناممکن میدانند و تبدیل آن به فضایی زنده، اصیل و ماندگار.</p>
|
||||
<p>این جهانبینی در تمام سالهای فعالیت ما، معماری و مهندسی را با هنر و کیفیت زندگی پیوند داده است. هر پروژه با یک ایده آغاز میشود و با وسواس در جزئیات، به اثری برای آینده تبدیل میگردد.</p>
|
||||
<p>از خانههای شاخص تا مجموعههای شهری، مسیر ما همواره با نوآوری، انتخاب بهترینها و اعتماد همراهانمان ادامه یافته است.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="oas-stats oas-reveal">
|
||||
<div><strong>۲۵</strong><span>سال تجربه</span></div>
|
||||
<div><strong>۱۵</strong><span>پروژهٔ شاخص</span></div>
|
||||
<div><strong>۰۶</strong><span>حوزهٔ تخصصی</span></div>
|
||||
</div>
|
||||
|
||||
<div className="oas-copy oas-copy-secondary oas-reveal">
|
||||
<span>تعالی در اجرا</span>
|
||||
<h2>پیشرو با<br />کیفیتی بیرقیب</h2>
|
||||
<div>
|
||||
<p>ما تنها سازنده نیستیم؛ شریک کسانی هستیم که برای کیفیت، اصالت و آینده معیارهای تازهای تعریف میکنند.</p>
|
||||
<p>همکاری با معماران، طراحان و متخصصان برجسته به ما امکان میدهد هر پروژه را با شخصیت مستقل و زبان معماری ویژهٔ خود خلق کنیم.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about-timeline" className="oas-timeline" aria-label="مسیر پروژههای رئوفی">
|
||||
<Swiper dir="ltr" loop={timeline.length > 1} speed={850} slidesPerView={1} grabCursor onSwiper={(swiper) => { timelineRef.current = swiper; setTimelineIndex(swiper.realIndex); }} onSlideChange={(swiper) => setTimelineIndex(swiper.realIndex)}>
|
||||
{timeline.map((project, index) => (
|
||||
<SwiperSlide key={`${project.slug || project.name}-${index}`}>
|
||||
<article dir="rtl">
|
||||
<div className="oas-timeline-media" role="img" aria-label={project.name} style={{ backgroundImage: `url("${project.image}")` }} />
|
||||
<div className="oas-shade" />
|
||||
<div className="oas-timeline-caption">
|
||||
<small>{project.type} / {String(index + 1).padStart(2, "0")}</small>
|
||||
<h3>{project.name}</h3>
|
||||
{project.slug && <Link href={`/portfolios/${project.slug}`}>مشاهدهٔ پروژه</Link>}
|
||||
</div>
|
||||
</article>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
<div className="oas-carousel-buttons">
|
||||
<button type="button" onClick={() => timelineRef.current?.slidePrev()} aria-label="اسلاید قبلی">→</button>
|
||||
<button type="button" onClick={() => timelineRef.current?.slideNext()} aria-label="اسلاید بعدی">←</button>
|
||||
</div>
|
||||
<div className="oas-timeline-dots">
|
||||
{timeline.map((project, index) => <button key={`${project.name}-dot`} type="button" className={timelineIndex === index ? "is-active" : ""} onClick={() => timelineRef.current?.slideToLoop(index)} aria-label={`نمایش اسلاید ${index + 1}`} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="oas-pillars">
|
||||
<header className="oas-section-heading oas-reveal">
|
||||
<span>ستونهای ما</span>
|
||||
<h2>بازآفرینیِ قواعد<br />معمول</h2>
|
||||
<p>پیوندی عمیق میان هنر و معماری، همراه با جاهطلبی همیشگی برای دستیابی به آنچه دستنیافتنی به نظر میرسد.</p>
|
||||
</header>
|
||||
|
||||
<div className="oas-pillar-carousel oas-reveal">
|
||||
<Swiper dir="ltr" loop speed={700} slidesPerView="auto" spaceBetween={34} grabCursor onSwiper={(swiper) => { pillarsRef.current = swiper; setPillarIndex(swiper.realIndex); }} onSlideChange={(swiper) => setPillarIndex(swiper.realIndex)}>
|
||||
{pillars.map((pillar) => (
|
||||
<SwiperSlide key={pillar.title}>
|
||||
<article dir="ltr">
|
||||
<div className="oas-pillar-image"><Image src={pillar.image} alt={pillar.title} fill sizes="(max-width: 700px) 82vw, 400px" /></div>
|
||||
<div className="oas-pillar-copy">
|
||||
<h3>{pillar.title}</h3>
|
||||
<p>{pillar.text}</p>
|
||||
</div>
|
||||
</article>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
<div className="oas-pillar-progress" aria-hidden="true">
|
||||
<span style={{ width: `${100 / pillars.length}%`, transform: `translateX(${pillarIndex * 100}%)` }} />
|
||||
</div>
|
||||
<div className="oas-carousel-buttons oas-pillar-buttons">
|
||||
<button type="button" onClick={() => pillarsRef.current?.slidePrev()} aria-label="ستون قبلی">←</button>
|
||||
<button type="button" onClick={() => pillarsRef.current?.slideNext()} aria-label="ستون بعدی">→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="oas-statements oas-reveal">
|
||||
<p>در رئوفی، نگاه آیندهنگر را یک گام فراتر میبریم. فضاهایی میسازیم که شخصیت شما را بازتاب میدهند، دستاوردهایتان را جشن میگیرند و با آرزوهایتان هممسیرند.</p>
|
||||
<p>دستاوردهای ما نتیجهٔ عبور پیوسته از مرزهای متعارف است؛ آثاری که اعتماد همراهان ما را به دست آوردهاند و افق تازهای برای معماری معاصر ساختهاند.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="oas-founder">
|
||||
<header className="oas-section-heading oas-reveal">
|
||||
<span>بنیانگذار ما</span>
|
||||
<h2>نگاهی پیشرو<br />در معماری</h2>
|
||||
</header>
|
||||
<picture className="oas-founder-image oas-reveal">
|
||||
<source media="(max-width: 700px)" srcSet={asset("founder-mobile.jpg")} />
|
||||
<img src={asset("founder-desktop.jpg")} alt="بنیانگذار گروه ساختمانی رئوفی" />
|
||||
</picture>
|
||||
<div className="oas-founder-copy oas-reveal">
|
||||
<div><h3>مهندس قاسم رئوفیمنش</h3><span>بنیانگذار و مدیرعامل</span></div>
|
||||
<p>دیدگاه مهندس قاسم رئوفیمنش، مسیر گروه ساختمانی رئوفی را از نخستین روز شکل داده است؛ دیدگاهی که معماری را فراتر از ساخت بنا میبیند و بر اصالت، نوآوری و کیفیت پایدار استوار است. تحت هدایت او، هر پروژه به تجربهای متمایز از زندگی و بخشی ماندگار از سیمای شهر تبدیل شده است.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="oas-enquire">
|
||||
<video autoPlay muted loop playsInline preload="metadata">
|
||||
<source src={asset("cta-mobile.mp4")} media="(max-width: 700px)" type="video/mp4" />
|
||||
<source src={asset("cta-desktop.mp4")} type="video/mp4" />
|
||||
</video>
|
||||
<div className="oas-shade" />
|
||||
<div className="oas-enquire-copy oas-reveal">
|
||||
<span>با ما گفتوگو کنید</span>
|
||||
<h2>ناممکن را کشف کنید</h2>
|
||||
<p>برای آشنایی بیشتر با پروژهها و فرصتهای همکاری با ما در تماس باشید.</p>
|
||||
<Link href="/contactus">درخواست تماس</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.oas-about{background:#fff;color:#111a3b;overflow-x:clip}.oas-about h1,.oas-about h2,.oas-about h3,.oas-about p{margin:0}.oas-shade{position:absolute;inset:0;background:linear-gradient(0deg,rgba(4,9,18,.58),rgba(4,9,18,.08) 60%)}
|
||||
.oas-hero{position:relative;height:150svh;min-height:900px;background:#111}.oas-hero-media{position:sticky;top:0;width:100%;height:100svh;overflow:hidden}.oas-hero video{display:block;width:100%;height:100%;object-fit:cover}.oas-hero-media>.oas-shade{position:absolute;inset:0}.oas-hero-title{position:absolute;z-index:2;top:40%;left:5%;right:5%;text-align:center;color:#fff}.oas-hero-title h1,.oas-hero-title h2{font-size:clamp(34px,4.6vw,67px);font-weight:300;line-height:1.35;color:#fff}.oas-hero-title h2{margin-top:5px}
|
||||
.oas-story{padding:145px 5vw 0}.oas-copy{max-width:1360px;margin:auto;text-align:center}.oas-copy>span,.oas-section-heading>span{display:block;color:#9a8568;font-size:12px;letter-spacing:.08em}.oas-copy h2{max-width:1100px;margin:42px auto 55px;font-size:clamp(30px,calc(5.5vw - 25px),54px);font-weight:300;line-height:1.25}.oas-copy>div{display:grid;gap:54px;max-width:1190px;margin:auto;color:#111a3b;font-size:clamp(16px,1.55vw,22px);line-height:2;text-align:center}.oas-copy>div p{max-width:1150px;margin-inline:auto}.oas-stats{display:grid;grid-template-columns:repeat(3,1fr);max-width:1060px;margin:150px auto 180px}.oas-stats div{display:flex;min-height:128px;flex-direction:column;justify-content:center;gap:10px;padding-inline:32px;border-inline-start:1px solid #c9cbd3}.oas-stats strong{font-size:clamp(50px,6vw,84px);font-weight:300;line-height:1}.oas-stats span{color:#777b85;font-size:14px}.oas-copy-secondary{padding-bottom:160px}.oas-copy-secondary>div{max-width:1100px}
|
||||
.oas-timeline{position:relative;height:100vh;height:100svh;min-height:650px;background:#111;overflow:hidden}.oas-timeline>.swiper{position:absolute;inset:0;height:auto}.oas-timeline .swiper-wrapper,.oas-timeline .swiper-slide,.oas-timeline article{height:100%}.oas-timeline article{position:relative;overflow:hidden;background:#111}.oas-timeline-media{position:absolute;z-index:0;inset:0;background-position:center;background-size:cover;background-repeat:no-repeat}.oas-timeline article>.oas-shade{z-index:1}.oas-timeline-caption{position:absolute;z-index:2;right:7%;bottom:12%;direction:rtl;color:#fff}.oas-timeline-caption small{font-size:12px}.oas-timeline-caption h3{max-width:700px;margin:12px 0 25px;font-size:clamp(37px,5vw,72px);font-weight:300;color:#fff}.oas-timeline-caption a{display:inline-block;border-bottom:1px solid #fff;padding-bottom:8px;color:#fff;font-size:13px}.oas-carousel-buttons{position:absolute;z-index:5;left:5%;bottom:8%;display:flex;gap:12px}.oas-carousel-buttons button{display:grid;width:48px;height:48px;place-items:center;border:1px solid rgba(255,255,255,.7);border-radius:50%;background:transparent;color:#fff;font-size:21px;cursor:pointer;transition:.3s}.oas-carousel-buttons button:hover{background:#fff;color:#111a3b}.oas-timeline-dots{position:absolute;z-index:5;right:7%;left:18%;bottom:6%;display:flex;direction:ltr}.oas-timeline-dots button{height:2px;flex:1;border:0;background:rgba(255,255,255,.35);padding:0;cursor:pointer}.oas-timeline-dots button.is-active{background:#fff}
|
||||
.oas-pillars{padding:150px 0;background:#f1efec}.oas-section-heading{width:min(1180px,90%);margin:0 auto 90px;text-align:center}.oas-section-heading h2{margin-top:25px;font-size:clamp(39px,5vw,70px);font-weight:300;line-height:1.25}.oas-section-heading p{max-width:760px;margin:28px auto 0;color:#676b75;line-height:2}.oas-pillar-carousel{position:relative;padding-left:2vw;padding-bottom:115px}.oas-pillar-carousel .swiper-slide{width:38vw;height:auto}.oas-pillar-carousel article{display:grid;height:19vw;grid-template-columns:1fr 1fr;background:#fff}.oas-pillar-image{position:relative;min-height:0;overflow:hidden}.oas-pillar-image img{object-fit:cover;transition:transform .8s}.oas-pillar-carousel article:hover img{transform:scale(1.035)}.oas-pillar-copy{display:flex;min-width:0;flex-direction:column;justify-content:space-between;padding:clamp(14px,1.8vw,44px) clamp(14px,1.5vw,36px);background:#fff;direction:rtl;text-align:right}.oas-pillar-carousel h3{margin:0;font-size:clamp(18px,1.65vw,36px);font-weight:300;line-height:1.35}.oas-pillar-carousel p{margin:0;color:#111a3b;font-size:clamp(11px,.9vw,16px);line-height:1.8}.oas-pillar-progress{position:absolute;z-index:4;right:38%;bottom:45px;width:23%;height:2px;background:#d0cfcc;overflow:hidden;direction:ltr}.oas-pillar-progress span{display:block;height:100%;background:#111a3b;transition:transform .7s cubic-bezier(.22,1,.36,1)}.oas-pillar-buttons{right:5vw;bottom:22px;left:auto;direction:ltr}.oas-pillar-buttons button{border:0;color:#111a3b;font-size:34px}.oas-pillar-buttons button:hover{background:transparent;color:#9a8568}.oas-statements{display:grid;grid-template-columns:1fr 1fr;gap:12vw;width:min(1060px,85%);margin:120px auto 0}.oas-statements p{font-size:clamp(20px,2.1vw,29px);font-weight:300;line-height:1.9}
|
||||
.oas-founder{padding:145px 0 0}.oas-founder-image{display:block;width:100%;height:min(58vw,760px);overflow:hidden}.oas-founder-image img{width:100%;height:100%;object-fit:cover}.oas-founder-copy{display:grid;grid-template-columns:.8fr 1.2fr;gap:10vw;width:min(1160px,90%);margin:90px auto 150px}.oas-founder-copy h3{font-size:clamp(30px,3vw,47px);font-weight:300}.oas-founder-copy span{display:block;margin-top:10px;color:#9a8568}.oas-founder-copy p{color:#575c6b;font-size:16px;line-height:2.1}
|
||||
.oas-enquire{position:relative;height:650px;margin:0 5vw 80px;overflow:hidden}.oas-enquire video{width:100%;height:100%;object-fit:cover}.oas-enquire-copy{position:absolute;z-index:2;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;color:#fff}.oas-enquire-copy span{font-size:12px}.oas-enquire-copy h2{margin:18px 0 5px;font-size:clamp(42px,5vw,72px);font-weight:300;color:#fff}.oas-enquire-copy p{margin-bottom:35px}.oas-enquire-copy a{min-width:220px;border:1px solid #fff;padding:13px 25px;color:#fff;transition:.3s}.oas-enquire-copy a:hover{background:#fff;color:#111a3b}
|
||||
.oas-reveal{opacity:0;transform:translateY(35px);transition:opacity .9s cubic-bezier(.22,1,.36,1),transform .9s cubic-bezier(.22,1,.36,1)}.oas-reveal.is-visible{opacity:1;transform:none}
|
||||
@media(max-width:700px){.oas-hero{height:120svh;min-height:720px}.oas-hero-title{top:36%}.oas-hero-title h1,.oas-hero-title h2{font-size:clamp(33px,9vw,50px)}.oas-story{padding:95px 22px 0}.oas-copy h2{margin:28px auto 38px;font-size:clamp(38px,10vw,52px)}.oas-copy>div{gap:32px;font-size:14px;line-height:2.15}.oas-stats{margin:90px 0 105px}.oas-stats div{min-height:95px;padding-inline:10px}.oas-stats strong{font-size:clamp(38px,11vw,56px)}.oas-stats span{font-size:10px}.oas-copy-secondary{padding-bottom:100px}.oas-timeline{height:78svh;min-height:570px}.oas-timeline-caption{right:25px;left:25px;bottom:18%}.oas-timeline-caption h3{font-size:36px}.oas-carousel-buttons{left:22px;bottom:5%}.oas-timeline-dots{right:22px;left:140px;bottom:8%}.oas-pillars{padding:100px 0}.oas-section-heading{margin-bottom:60px}.oas-section-heading h2{font-size:clamp(38px,10vw,54px)}.oas-section-heading p{font-size:14px}.oas-pillar-carousel{padding-left:18px;padding-bottom:100px}.oas-pillar-carousel .swiper-slide{width:88vw}.oas-pillar-carousel article{height:auto;min-height:0;grid-template-columns:1fr;aspect-ratio:auto}.oas-pillar-image{min-height:0;aspect-ratio:1}.oas-pillar-copy{min-height:260px;padding:30px 24px}.oas-pillar-carousel h3{font-size:24px}.oas-pillar-carousel p{font-size:14px}.oas-pillar-progress{right:150px;bottom:42px;width:calc(100% - 175px)}.oas-pillar-buttons{right:18px;bottom:19px;left:auto}.oas-statements{grid-template-columns:1fr;gap:55px;margin-top:80px}.oas-statements p{font-size:20px}.oas-founder{padding-top:100px}.oas-founder-image{height:125vw;max-height:680px}.oas-founder-copy{grid-template-columns:1fr;gap:35px;margin-block:65px 100px}.oas-founder-copy p{font-size:14px}.oas-enquire{height:560px;margin:0}.oas-enquire-copy{padding:25px}.oas-enquire-copy p{font-size:14px}}
|
||||
@media(prefers-reduced-motion:reduce){.oas-reveal{opacity:1;transform:none}.oas-pillar-image img{transition:none}}
|
||||
@@ -110,6 +110,19 @@ async function apiGetOptional<T>(path: string, options?: FetchOptions): Promise<
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public pages must remain usable when the CMS is briefly unavailable. List
|
||||
* views render their existing empty state and detail views resolve as missing
|
||||
* instead of turning an upstream network failure into a site-wide 500 page.
|
||||
*/
|
||||
async function withApiFallback<T>(load: () => Promise<T>, fallback: T): Promise<T> {
|
||||
try {
|
||||
return await load();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop body HTML and gallery arrays from list responses — not needed for cards/grids. */
|
||||
function stripListHeavyFields<T extends ApiPortfolioItem | ApiBlogItem>(item: T): T {
|
||||
return {
|
||||
@@ -139,9 +152,12 @@ async function fetchAllPages<T extends { items: unknown[]; total: number; pageSi
|
||||
|
||||
/** All published portfolio items, every page merged, in the CMS's own order. */
|
||||
export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
|
||||
const items = await fetchAllPages<PortfoliosResponse>(
|
||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
|
||||
{ tags: ["portfolios"] },
|
||||
const items = await withApiFallback(
|
||||
() => fetchAllPages<PortfoliosResponse>(
|
||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
|
||||
{ tags: ["portfolios"] },
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (items as ApiPortfolioItem[])
|
||||
@@ -153,9 +169,12 @@ export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
|
||||
/** Recent published portfolios for home / about / services carousels. */
|
||||
export const getRecentPortfolios = cache(async (limit = 12): Promise<ApiPortfolioItem[]> => {
|
||||
const pageSize = Math.min(Math.max(limit, 1), 50);
|
||||
const first = await apiGet<PortfoliosResponse>(
|
||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`,
|
||||
{ tags: ["portfolios"] },
|
||||
const first = await withApiFallback(
|
||||
() => apiGet<PortfoliosResponse>(
|
||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`,
|
||||
{ tags: ["portfolios"] },
|
||||
),
|
||||
{ items: [], total: 0, page: 1, pageSize },
|
||||
);
|
||||
|
||||
return first.items
|
||||
@@ -167,9 +186,12 @@ export const getRecentPortfolios = cache(async (limit = 12): Promise<ApiPortfoli
|
||||
|
||||
/** Slugs only — for generateStaticParams without retaining full list payloads. */
|
||||
export const getPortfolioSlugs = cache(async (): Promise<string[]> => {
|
||||
const items = await fetchAllPages<PortfoliosResponse>(
|
||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
|
||||
{ tags: ["portfolios"] },
|
||||
const items = await withApiFallback(
|
||||
() => fetchAllPages<PortfoliosResponse>(
|
||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
|
||||
{ tags: ["portfolios"] },
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (items as ApiPortfolioItem[])
|
||||
@@ -179,12 +201,15 @@ export const getPortfolioSlugs = cache(async (): Promise<string[]> => {
|
||||
|
||||
/** Single portfolio by slug — avoids loading the full list on detail pages. */
|
||||
export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfolioItem | null> => {
|
||||
const data = await apiGetOptional<{ portfolio: ApiPortfolioItem }>(
|
||||
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
revalidate: REVALIDATE_DETAIL_SECONDS,
|
||||
tags: ["portfolios", `portfolio:${slug}`],
|
||||
},
|
||||
const data = await withApiFallback(
|
||||
() => apiGetOptional<{ portfolio: ApiPortfolioItem }>(
|
||||
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
revalidate: REVALIDATE_DETAIL_SECONDS,
|
||||
tags: ["portfolios", `portfolio:${slug}`],
|
||||
},
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const item = data?.portfolio;
|
||||
@@ -313,7 +338,10 @@ function blogListPath(type?: BlogType): string {
|
||||
/** All published blog/news items, every page merged. Optionally filtered by `type`. */
|
||||
export const getBlogs = cache(async (type?: BlogType): Promise<ApiBlogItem[]> => {
|
||||
const tag = type ? `blogs:${type}` : "blogs";
|
||||
const items = await fetchAllPages<BlogsResponse>(blogListPath(type), { tags: [tag, "blogs"] });
|
||||
const items = await withApiFallback(
|
||||
() => fetchAllPages<BlogsResponse>(blogListPath(type), { tags: [tag, "blogs"] }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (items as ApiBlogItem[])
|
||||
.filter((item) => item.status === "published")
|
||||
@@ -324,9 +352,12 @@ export const getBlogs = cache(async (type?: BlogType): Promise<ApiBlogItem[]> =>
|
||||
/** Recent news posts for the home page carousel. */
|
||||
export const getRecentNews = cache(async (limit = 3): Promise<ApiBlogItem[]> => {
|
||||
const pageSize = Math.min(Math.max(limit, 1), 50);
|
||||
const first = await apiGet<BlogsResponse>(
|
||||
`/tenants/${SITE_DOMAIN}/blogs?type=news&page=1&pageSize=${pageSize}`,
|
||||
{ tags: ["blogs", "blogs:news"] },
|
||||
const first = await withApiFallback(
|
||||
() => apiGet<BlogsResponse>(
|
||||
`/tenants/${SITE_DOMAIN}/blogs?type=news&page=1&pageSize=${pageSize}`,
|
||||
{ tags: ["blogs", "blogs:news"] },
|
||||
),
|
||||
{ items: [], total: 0, page: 1, pageSize },
|
||||
);
|
||||
|
||||
return first.items
|
||||
@@ -339,7 +370,10 @@ export const getRecentNews = cache(async (limit = 3): Promise<ApiBlogItem[]> =>
|
||||
/** Slugs for generateStaticParams. */
|
||||
export const getBlogSlugs = cache(async (type?: BlogType): Promise<string[]> => {
|
||||
const tag = type ? `blogs:${type}` : "blogs";
|
||||
const items = await fetchAllPages<BlogsResponse>(blogListPath(type), { tags: [tag, "blogs"] });
|
||||
const items = await withApiFallback(
|
||||
() => fetchAllPages<BlogsResponse>(blogListPath(type), { tags: [tag, "blogs"] }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (items as ApiBlogItem[])
|
||||
.filter((item) => item.status === "published" && (type ? item.type === type : item.type !== "news"))
|
||||
@@ -348,12 +382,15 @@ export const getBlogSlugs = cache(async (type?: BlogType): Promise<string[]> =>
|
||||
|
||||
/** Single blog/news item by slug — avoids loading the full list on detail pages. */
|
||||
export const getBlogBySlug = cache(async (slug: string): Promise<ApiBlogItem | null> => {
|
||||
const data = await apiGetOptional<{ blog: ApiBlogItem }>(
|
||||
`/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
revalidate: REVALIDATE_DETAIL_SECONDS,
|
||||
tags: ["blogs", `blog:${slug}`],
|
||||
},
|
||||
const data = await withApiFallback(
|
||||
() => apiGetOptional<{ blog: ApiBlogItem }>(
|
||||
`/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
revalidate: REVALIDATE_DETAIL_SECONDS,
|
||||
tags: ["blogs", `blog:${slug}`],
|
||||
},
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const item = data?.blog;
|
||||
|
||||