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", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "node scripts/dev.cjs",
"build": "next build", "build": "next build",
"start": "next start" "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 type { Metadata } from "next";
import atelier from "@/assets/images/about/atelier.jpg"; import { OmniyatAbout } from "@/components/about/OmniyatAbout";
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 { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader"; import { SiteHeader } from "@/components/SiteHeader";
import { aboutPage } from "@/data/site";
import { getRecentPortfolios, toDisplayProject } from "@/lib/api"; 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 = { export const metadata: Metadata = {
title: "درباره ما", title: "درباره ما",
description: description: "روایت شکل‌گیری، ارزش‌ها و مسیر گروه ساختمانی رئوفی از سال ۱۳۷۹ تا امروز.",
"از سال ۱۳۷۹، دیدگاه مهندس قاسم رئوفی‌منش مسیر گروه رئوفی را تعریف کرده است؛ دیدگاهی که هر پروژه را به تجربه‌ای اصیل و ماندگار از سبک زندگی ارتقاء می‌دهد.",
}; };
export default async function AboutPage() { export default async function AboutPage() {
const portfolios = await getRecentPortfolios(12); const portfolios = await getRecentPortfolios(8);
const displayProjects = portfolios.map(toDisplayProject); const displayProjects = portfolios.map(toDisplayProject);
return ( return (
<> <>
<SiteHeader /> <SiteHeader />
<OmniyatAbout projects={displayProjects} />
<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>
<SiteFooter /> <SiteFooter />
</> </>
); );
+57 -93
View File
@@ -5,20 +5,11 @@ import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import logo from "@/assets/images/brand/logo.png"; import logo from "@/assets/images/brand/logo.png";
import { contact, nav, site } from "@/data/site"; import { nav, site } from "@/data/site";
export function SiteHeader() { export function SiteHeader() {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = 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(() => { useEffect(() => {
document.body.style.overflow = menuOpen ? "hidden" : ""; document.body.style.overflow = menuOpen ? "hidden" : "";
return () => { return () => {
@@ -27,103 +18,76 @@ export function SiteHeader() {
}, [menuOpen]); }, [menuOpen]);
return ( return (
<header <>
className={`fixed inset-x-0 top-0 z-50 transition-[background-color,backdrop-filter,box-shadow,padding] duration-500 ${ <header
scrolled || menuOpen dir="rtl"
? "bg-ink/92 py-3 shadow-[0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-xl" 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%]"
: "bg-gradient-to-b from-black/55 to-transparent py-6" >
}`} <button
> type="button"
<div className="mx-auto flex max-w-[1400px] items-center justify-between gap-6 px-5 lg:px-10"> onClick={() => setMenuOpen(true)}
<Link href="/" aria-label={site.name} className="shrink-0"> 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 <Image
src={logo} src={logo}
alt={site.name} alt={site.name}
priority priority
className="h-8 w-auto brightness-0 invert lg:h-9" className="h-auto w-24 brightness-0 invert lg:w-[120px]"
/> />
</Link> </Link>
<nav aria-label="فهرست اصلی" className="hidden lg:block"> <Link
<ul className="flex items-center gap-9"> href="/contactus"
{nav.map((item) => ( 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]"
<li key={item.href}> >
<Link تماس با ما
href={item.href} </Link>
className="group relative block py-1 text-[0.94rem] text-white/85 transition-colors hover:text-white" </header>
>
{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 <div
id="mobile-menu" dir="rtl"
className={`overflow-hidden bg-ink transition-[max-height,opacity] duration-500 ease-out-soft lg:hidden ${ aria-hidden={!menuOpen}
menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0" 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"> <button
{nav.map((item) => ( type="button"
<li key={item.href}> onClick={() => setMenuOpen(false)}
<Link aria-label="بستن فهرست"
href={item.href} className="self-start text-[38px] leading-none"
onClick={() => setMenuOpen(false)} >
className="block border-b border-white/10 py-3.5 text-white/85 transition-colors hover:text-gold" ×
> </button>
{item.label}
</Link> <nav aria-label="فهرست اصلی" className="m-auto grid w-full max-w-[680px]">
</li> {nav.map((item, index) => (
))}
<li className="pt-5">
<Link <Link
href={contact.loginUrl} key={item.href}
className="block rounded-full bg-gold py-3 text-center text-white" 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> </Link>
</li> ))}
</ul> </nav>
<span className="text-xs text-white/60">هنرِ ساختنِ زندگی</span>
</div> </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>; 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. */ /** Drop body HTML and gallery arrays from list responses — not needed for cards/grids. */
function stripListHeavyFields<T extends ApiPortfolioItem | ApiBlogItem>(item: T): T { function stripListHeavyFields<T extends ApiPortfolioItem | ApiBlogItem>(item: T): T {
return { 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. */ /** All published portfolio items, every page merged, in the CMS's own order. */
export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => { export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
const items = await fetchAllPages<PortfoliosResponse>( const items = await withApiFallback(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`, () => fetchAllPages<PortfoliosResponse>(
{ tags: ["portfolios"] }, `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
{ tags: ["portfolios"] },
),
[],
); );
return (items as ApiPortfolioItem[]) return (items as ApiPortfolioItem[])
@@ -153,9 +169,12 @@ export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
/** Recent published portfolios for home / about / services carousels. */ /** Recent published portfolios for home / about / services carousels. */
export const getRecentPortfolios = cache(async (limit = 12): Promise<ApiPortfolioItem[]> => { export const getRecentPortfolios = cache(async (limit = 12): Promise<ApiPortfolioItem[]> => {
const pageSize = Math.min(Math.max(limit, 1), 50); const pageSize = Math.min(Math.max(limit, 1), 50);
const first = await apiGet<PortfoliosResponse>( const first = await withApiFallback(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`, () => apiGet<PortfoliosResponse>(
{ tags: ["portfolios"] }, `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`,
{ tags: ["portfolios"] },
),
{ items: [], total: 0, page: 1, pageSize },
); );
return first.items 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. */ /** Slugs only — for generateStaticParams without retaining full list payloads. */
export const getPortfolioSlugs = cache(async (): Promise<string[]> => { export const getPortfolioSlugs = cache(async (): Promise<string[]> => {
const items = await fetchAllPages<PortfoliosResponse>( const items = await withApiFallback(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`, () => fetchAllPages<PortfoliosResponse>(
{ tags: ["portfolios"] }, `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
{ tags: ["portfolios"] },
),
[],
); );
return (items as ApiPortfolioItem[]) 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. */ /** Single portfolio by slug — avoids loading the full list on detail pages. */
export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfolioItem | null> => { export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfolioItem | null> => {
const data = await apiGetOptional<{ portfolio: ApiPortfolioItem }>( const data = await withApiFallback(
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`, () => apiGetOptional<{ portfolio: ApiPortfolioItem }>(
{ `/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
revalidate: REVALIDATE_DETAIL_SECONDS, {
tags: ["portfolios", `portfolio:${slug}`], revalidate: REVALIDATE_DETAIL_SECONDS,
}, tags: ["portfolios", `portfolio:${slug}`],
},
),
null,
); );
const item = data?.portfolio; 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`. */ /** All published blog/news items, every page merged. Optionally filtered by `type`. */
export const getBlogs = cache(async (type?: BlogType): Promise<ApiBlogItem[]> => { export const getBlogs = cache(async (type?: BlogType): Promise<ApiBlogItem[]> => {
const tag = type ? `blogs:${type}` : "blogs"; 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[]) return (items as ApiBlogItem[])
.filter((item) => item.status === "published") .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. */ /** Recent news posts for the home page carousel. */
export const getRecentNews = cache(async (limit = 3): Promise<ApiBlogItem[]> => { export const getRecentNews = cache(async (limit = 3): Promise<ApiBlogItem[]> => {
const pageSize = Math.min(Math.max(limit, 1), 50); const pageSize = Math.min(Math.max(limit, 1), 50);
const first = await apiGet<BlogsResponse>( const first = await withApiFallback(
`/tenants/${SITE_DOMAIN}/blogs?type=news&page=1&pageSize=${pageSize}`, () => apiGet<BlogsResponse>(
{ tags: ["blogs", "blogs:news"] }, `/tenants/${SITE_DOMAIN}/blogs?type=news&page=1&pageSize=${pageSize}`,
{ tags: ["blogs", "blogs:news"] },
),
{ items: [], total: 0, page: 1, pageSize },
); );
return first.items return first.items
@@ -339,7 +370,10 @@ export const getRecentNews = cache(async (limit = 3): Promise<ApiBlogItem[]> =>
/** Slugs for generateStaticParams. */ /** Slugs for generateStaticParams. */
export const getBlogSlugs = cache(async (type?: BlogType): Promise<string[]> => { export const getBlogSlugs = cache(async (type?: BlogType): Promise<string[]> => {
const tag = type ? `blogs:${type}` : "blogs"; 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[]) return (items as ApiBlogItem[])
.filter((item) => item.status === "published" && (type ? item.type === type : item.type !== "news")) .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. */ /** Single blog/news item by slug — avoids loading the full list on detail pages. */
export const getBlogBySlug = cache(async (slug: string): Promise<ApiBlogItem | null> => { export const getBlogBySlug = cache(async (slug: string): Promise<ApiBlogItem | null> => {
const data = await apiGetOptional<{ blog: ApiBlogItem }>( const data = await withApiFallback(
`/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`, () => apiGetOptional<{ blog: ApiBlogItem }>(
{ `/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`,
revalidate: REVALIDATE_DETAIL_SECONDS, {
tags: ["blogs", `blog:${slug}`], revalidate: REVALIDATE_DETAIL_SECONDS,
}, tags: ["blogs", `blog:${slug}`],
},
),
null,
); );
const item = data?.blog; const item = data?.blog;