Redesign about page and stabilize site data

This commit is contained in:
amirhosein.ashourloo
2026-09-21 18:10:21 +03:30
parent fe4c110eaf
commit 6b3e87a09d
21 changed files with 370 additions and 215 deletions
+1 -1
View File
@@ -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"
},
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

+48
View File
@@ -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);
});
+5 -95
View File
@@ -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 />
</>
);
+57 -93
View File
@@ -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>
</>
);
}
+186
View File
@@ -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>
);
}
+10
View File
@@ -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}}
+63 -26
View File
@@ -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;