diff --git a/package.json b/package.json index 6a86da3..a140fce 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/public/omniyat-reference/about/cta-desktop.mp4 b/public/omniyat-reference/about/cta-desktop.mp4 new file mode 100644 index 0000000..bce0a4d Binary files /dev/null and b/public/omniyat-reference/about/cta-desktop.mp4 differ diff --git a/public/omniyat-reference/about/cta-mobile.mp4 b/public/omniyat-reference/about/cta-mobile.mp4 new file mode 100644 index 0000000..9b5c370 Binary files /dev/null and b/public/omniyat-reference/about/cta-mobile.mp4 differ diff --git a/public/omniyat-reference/about/founder-desktop.jpg b/public/omniyat-reference/about/founder-desktop.jpg new file mode 100644 index 0000000..b0d6bbf Binary files /dev/null and b/public/omniyat-reference/about/founder-desktop.jpg differ diff --git a/public/omniyat-reference/about/founder-mobile.jpg b/public/omniyat-reference/about/founder-mobile.jpg new file mode 100644 index 0000000..1702df9 Binary files /dev/null and b/public/omniyat-reference/about/founder-mobile.jpg differ diff --git a/public/omniyat-reference/about/hero-desktop.mp4 b/public/omniyat-reference/about/hero-desktop.mp4 new file mode 100644 index 0000000..c1573d6 Binary files /dev/null and b/public/omniyat-reference/about/hero-desktop.mp4 differ diff --git a/public/omniyat-reference/about/hero-mobile.mp4 b/public/omniyat-reference/about/hero-mobile.mp4 new file mode 100644 index 0000000..eaea4b9 Binary files /dev/null and b/public/omniyat-reference/about/hero-mobile.mp4 differ diff --git a/public/omniyat-reference/about/pillar-1.webp b/public/omniyat-reference/about/pillar-1.webp new file mode 100644 index 0000000..3f79275 Binary files /dev/null and b/public/omniyat-reference/about/pillar-1.webp differ diff --git a/public/omniyat-reference/about/pillar-2.webp b/public/omniyat-reference/about/pillar-2.webp new file mode 100644 index 0000000..da41345 Binary files /dev/null and b/public/omniyat-reference/about/pillar-2.webp differ diff --git a/public/omniyat-reference/about/pillar-3.webp b/public/omniyat-reference/about/pillar-3.webp new file mode 100644 index 0000000..a8c80be Binary files /dev/null and b/public/omniyat-reference/about/pillar-3.webp differ diff --git a/public/omniyat-reference/about/pillar-4.webp b/public/omniyat-reference/about/pillar-4.webp new file mode 100644 index 0000000..e9519fc Binary files /dev/null and b/public/omniyat-reference/about/pillar-4.webp differ diff --git a/public/omniyat-reference/about/timeline-1.webp b/public/omniyat-reference/about/timeline-1.webp new file mode 100644 index 0000000..bcee0d3 Binary files /dev/null and b/public/omniyat-reference/about/timeline-1.webp differ diff --git a/public/omniyat-reference/about/timeline-2.webp b/public/omniyat-reference/about/timeline-2.webp new file mode 100644 index 0000000..8af2645 Binary files /dev/null and b/public/omniyat-reference/about/timeline-2.webp differ diff --git a/public/omniyat-reference/about/timeline-3.webp b/public/omniyat-reference/about/timeline-3.webp new file mode 100644 index 0000000..5b38051 Binary files /dev/null and b/public/omniyat-reference/about/timeline-3.webp differ diff --git a/public/omniyat-reference/about/timeline-4.webp b/public/omniyat-reference/about/timeline-4.webp new file mode 100644 index 0000000..8f80816 Binary files /dev/null and b/public/omniyat-reference/about/timeline-4.webp differ diff --git a/scripts/dev.cjs b/scripts/dev.cjs new file mode 100644 index 0000000..ac50497 --- /dev/null +++ b/scripts/dev.cjs @@ -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); +}); diff --git a/src/app/aboutus/page.tsx b/src/app/aboutus/page.tsx index 23bfd28..afd28b1 100644 --- a/src/app/aboutus/page.tsx +++ b/src/app/aboutus/page.tsx @@ -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 ( <> - -
- - - {/* Overview */} -
- - -
- - {/* Philosophy */} -
- -
- - {/* Foundations */} -
- - -
- - {/* Portfolio */} -
- - -
- - {/* Closing statement */} -
-
- -

- {aboutPage.closing.body} -

-
- - - -
- - -
- - -
- + ); diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index fe5e5de..66d7b3e 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -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 ( -
-
- + <> +
+ + + {site.name} - + + تماس با ما + +
-
- - ورود - - - -
-
- - {/* Mobile drawer */}
-
    - {nav.map((item) => ( -
  • - setMenuOpen(false)} - className="block border-b border-white/10 py-3.5 text-white/85 transition-colors hover:text-gold" - > - {item.label} - -
  • - ))} -
  • + + +
  • -
+ ))} + + + هنرِ ساختنِ زندگی
-
+ ); } diff --git a/src/components/about/OmniyatAbout.tsx b/src/components/about/OmniyatAbout.tsx new file mode 100644 index 0000000..23ed046 --- /dev/null +++ b/src/components/about/OmniyatAbout.tsx @@ -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(null); + const pillarsRef = useRef(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 ( +
+
+
+ +
+
+

از چشم‌انداز تا واقعیت

+

بیست و پنج سال در ساختِ ناممکن

+
+
+
+ +
+
+ داستان ما +

هر اتفاق بزرگ
با یک رؤیا آغاز شد

+
+

از سال ۱۳۷۹، گروه ساختمانی رئوفی با یک نگاه روشن شکل گرفت: تصور آنچه دیگران ناممکن می‌دانند و تبدیل آن به فضایی زنده، اصیل و ماندگار.

+

این جهان‌بینی در تمام سال‌های فعالیت ما، معماری و مهندسی را با هنر و کیفیت زندگی پیوند داده است. هر پروژه با یک ایده آغاز می‌شود و با وسواس در جزئیات، به اثری برای آینده تبدیل می‌گردد.

+

از خانه‌های شاخص تا مجموعه‌های شهری، مسیر ما همواره با نوآوری، انتخاب بهترین‌ها و اعتماد همراهانمان ادامه یافته است.

+
+
+ +
+
۲۵سال تجربه
+
۱۵پروژهٔ شاخص
+
۰۶حوزهٔ تخصصی
+
+ +
+ تعالی در اجرا +

پیشرو با
کیفیتی بی‌رقیب

+
+

ما تنها سازنده نیستیم؛ شریک کسانی هستیم که برای کیفیت، اصالت و آینده معیارهای تازه‌ای تعریف می‌کنند.

+

همکاری با معماران، طراحان و متخصصان برجسته به ما امکان می‌دهد هر پروژه را با شخصیت مستقل و زبان معماری ویژهٔ خود خلق کنیم.

+
+
+
+ +
+ 1} speed={850} slidesPerView={1} grabCursor onSwiper={(swiper) => { timelineRef.current = swiper; setTimelineIndex(swiper.realIndex); }} onSlideChange={(swiper) => setTimelineIndex(swiper.realIndex)}> + {timeline.map((project, index) => ( + +
+
+
+
+ {project.type} / {String(index + 1).padStart(2, "0")} +

{project.name}

+ {project.slug && مشاهدهٔ پروژه} +
+
+
+ ))} +
+
+ + +
+
+ {timeline.map((project, index) =>
+
+ +
+
+ ستون‌های ما +

بازآفرینیِ قواعد
معمول

+

پیوندی عمیق میان هنر و معماری، همراه با جاه‌طلبی همیشگی برای دستیابی به آنچه دست‌نیافتنی به نظر می‌رسد.

+
+ +
+ { pillarsRef.current = swiper; setPillarIndex(swiper.realIndex); }} onSlideChange={(swiper) => setPillarIndex(swiper.realIndex)}> + {pillars.map((pillar) => ( + +
+
{pillar.title}
+
+

{pillar.title}

+

{pillar.text}

+
+
+
+ ))} +
+ +
+ + +
+
+ +
+

در رئوفی، نگاه آینده‌نگر را یک گام فراتر می‌بریم. فضاهایی می‌سازیم که شخصیت شما را بازتاب می‌دهند، دستاوردهایتان را جشن می‌گیرند و با آرزوهایتان هم‌مسیرند.

+

دستاوردهای ما نتیجهٔ عبور پیوسته از مرزهای متعارف است؛ آثاری که اعتماد همراهان ما را به دست آورده‌اند و افق تازه‌ای برای معماری معاصر ساخته‌اند.

+
+
+ +
+
+ بنیان‌گذار ما +

نگاهی پیشرو
در معماری

+
+ + + بنیان‌گذار گروه ساختمانی رئوفی + +
+

مهندس قاسم رئوفی‌منش

بنیان‌گذار و مدیرعامل
+

دیدگاه مهندس قاسم رئوفی‌منش، مسیر گروه ساختمانی رئوفی را از نخستین روز شکل داده است؛ دیدگاهی که معماری را فراتر از ساخت بنا می‌بیند و بر اصالت، نوآوری و کیفیت پایدار استوار است. تحت هدایت او، هر پروژه به تجربه‌ای متمایز از زندگی و بخشی ماندگار از سیمای شهر تبدیل شده است.

+
+
+ +
+ +
+
+ با ما گفت‌وگو کنید +

ناممکن را کشف کنید

+

برای آشنایی بیشتر با پروژه‌ها و فرصت‌های همکاری با ما در تماس باشید.

+ درخواست تماس +
+
+
+ ); +} diff --git a/src/components/about/omniyat-about.css b/src/components/about/omniyat-about.css new file mode 100644 index 0000000..3559398 --- /dev/null +++ b/src/components/about/omniyat-about.css @@ -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}} diff --git a/src/lib/api.ts b/src/lib/api.ts index f7bc61c..943f60b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -110,6 +110,19 @@ async function apiGetOptional(path: string, options?: FetchOptions): Promise< return res.json() as Promise; } +/** + * 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(load: () => Promise, fallback: T): Promise { + try { + return await load(); + } catch { + return fallback; + } +} + /** Drop body HTML and gallery arrays from list responses — not needed for cards/grids. */ function stripListHeavyFields(item: T): T { return { @@ -139,9 +152,12 @@ async function fetchAllPages => { - const items = await fetchAllPages( - `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`, - { tags: ["portfolios"] }, + const items = await withApiFallback( + () => fetchAllPages( + `/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 => { /** Recent published portfolios for home / about / services carousels. */ export const getRecentPortfolios = cache(async (limit = 12): Promise => { const pageSize = Math.min(Math.max(limit, 1), 50); - const first = await apiGet( - `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`, - { tags: ["portfolios"] }, + const first = await withApiFallback( + () => apiGet( + `/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 => { - const items = await fetchAllPages( - `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`, - { tags: ["portfolios"] }, + const items = await withApiFallback( + () => fetchAllPages( + `/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 => { /** Single portfolio by slug — avoids loading the full list on detail pages. */ export const getPortfolioBySlug = cache(async (slug: string): Promise => { - 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 => { const tag = type ? `blogs:${type}` : "blogs"; - const items = await fetchAllPages(blogListPath(type), { tags: [tag, "blogs"] }); + const items = await withApiFallback( + () => fetchAllPages(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 => /** Recent news posts for the home page carousel. */ export const getRecentNews = cache(async (limit = 3): Promise => { const pageSize = Math.min(Math.max(limit, 1), 50); - const first = await apiGet( - `/tenants/${SITE_DOMAIN}/blogs?type=news&page=1&pageSize=${pageSize}`, - { tags: ["blogs", "blogs:news"] }, + const first = await withApiFallback( + () => apiGet( + `/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 => /** Slugs for generateStaticParams. */ export const getBlogSlugs = cache(async (type?: BlogType): Promise => { const tag = type ? `blogs:${type}` : "blogs"; - const items = await fetchAllPages(blogListPath(type), { tags: [tag, "blogs"] }); + const items = await withApiFallback( + () => fetchAllPages(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 => /** Single blog/news item by slug — avoids loading the full list on detail pages. */ export const getBlogBySlug = cache(async (slug: string): Promise => { - 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;