Redesign core pages and connect dynamic content

This commit is contained in:
amirhosein.ashourloo
2026-10-08 18:42:19 +03:30
parent 55d0fad2c8
commit 1d320b8f10
88 changed files with 1200 additions and 959 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ src/
## نکات
- لینک‌های ناوبری به مسیرهای واقعی سایت (`/portfolios`، `/news`، …) اشاره می‌کنند
- لینک‌های ناوبری به مسیرهای واقعی سایت (`/projects`، `/news`، …) اشاره می‌کنند
ولی تاکنون فقط صفحه اصلی و درباره ما طراحی شده‌اند.
- انیمیشن‌ها به `prefers-reduced-motion` احترام می‌گذارند.
- کروسل پروژه‌ها برای RTL نوشته شده (`scrollLeft` منفی) و با کیبورد و لمس کار می‌کند.
+15 -1
View File
@@ -7,7 +7,7 @@ const nextConfig: NextConfig = {
formats: ["image/avif", "image/webp"],
// Every quality value used across the page. Required from Next.js 16 on.
qualities: [70, 72, 75, 80, 82, 88, 90],
// Portfolio media served live from the meshkee API. The storage host has
// Project media served live from the meshkee API. The storage host has
// already changed once mid-project (parminstorage.ir -> parspack.net) —
// keep both around since either could be what a given item still points to.
remotePatterns: [
@@ -16,6 +16,20 @@ const nextConfig: NextConfig = {
{ protocol: "https", hostname: "c804387.parspack.net" },
],
},
async redirects() {
return [
{
source: "/our-services",
destination: "/atelier",
permanent: true,
},
{
source: "/portfolios/:path*",
destination: "/projects/:path*",
permanent: true,
},
];
},
};
export default nextConfig;
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

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

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
import { RaoofiAbout } from "@/components/about/RaoofiAbout";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getRecentPortfolios, toDisplayProject } from "@/lib/api";
import { getRecentProjects, toDisplayProject } from "@/lib/api";
export const revalidate = 300;
@@ -13,8 +13,8 @@ export const metadata: Metadata = {
};
export default async function AboutPage() {
const portfolios = await getRecentPortfolios(8);
const displayProjects = portfolios.map(toDisplayProject);
const projects = await getRecentProjects(8);
const displayProjects = projects.map(toDisplayProject);
return (
<>
+5 -4
View File
@@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from "next/server";
type RevalidateBody = {
/** Revalidate a fetch cache tag (portfolios, blogs, portfolio:slug, blog:slug, …). */
tag?: string;
/** Revalidate a path (/portfolios/foo, /, …). */
/** Revalidate a path (/projects/foo, /, …). */
path?: string;
/** Shorthand: portfolios | blogs | all */
scope?: "portfolios" | "blogs" | "all";
@@ -15,7 +15,7 @@ type RevalidateBody = {
*
* POST /api/revalidate
* Header: x-revalidate-secret: <REVALIDATE_SECRET>
* Body (JSON, optional): { "scope": "all" } | { "tag": "portfolios" } | { "path": "/portfolios/item" }
* Body (JSON, optional): { "scope": "all" } | { "tag": "portfolios" } | { "path": "/projects/item" }
*
* nginx may still serve cached HTML for up to its TTL (5m) unless purged separately.
*/
@@ -50,9 +50,10 @@ export async function POST(req: NextRequest) {
} else if (scope === "portfolios" || scope === "all") {
revalidateTag("portfolios");
revalidatePath("/");
revalidatePath("/portfolios");
revalidatePath("/projects");
revalidatePath("/aboutus");
revalidatePath("/our-services");
revalidatePath("/atelier");
revalidatePath("/raoofi-home");
revalidated.push("scope:portfolios");
}
+8 -3
View File
@@ -3,17 +3,22 @@ import type { Metadata } from "next";
import { RaoofiAtelier } from "@/components/atelier/RaoofiAtelier";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getRecentProjects, toDisplayProject } from "@/lib/api";
export const revalidate = 300;
export const metadata: Metadata = {
title: "آتلیه رئوفی",
description: "آتلیه رئوفی؛ خلق فضاهای شخصی، کمیاب و ماندگار با طراحی و اجرای یکپارچه.",
description: "آتلیه رئوفی؛ خدمات یکپارچه از نخستین ایده و طراحی تا مدیریت، اجرا و تحویل نهایی.",
};
export default function AtelierPage() {
export default async function AtelierPage() {
const projects = await getRecentProjects(12);
return (
<>
<SiteHeader />
<RaoofiAtelier />
<RaoofiAtelier projects={projects.map(toDisplayProject)} />
<SiteFooter />
</>
);
+9 -4
View File
@@ -4,21 +4,26 @@ import { notFound } from "next/navigation";
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getDemoNewsItem, getDemoNewsItems } from "@/data/news-demo";
import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api";
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
async function getBlogArticleBySlug(slug: string) {
const demoItem = getDemoNewsItem(slug, "blog");
if (demoItem) return demoItem;
const item = await getBlogBySlug(slug);
if (!item || item.type === "news") {
return null;
}
return item;
return toDisplayNewsItem(item);
}
export async function generateStaticParams() {
const slugs = await getBlogSlugs();
return slugs.map((slug) => ({ slug }));
return [...new Set([...slugs, ...getDemoNewsItems("blog").map((item) => item.slug)])]
.map((slug) => ({ slug }));
}
export async function generateMetadata({
@@ -29,7 +34,7 @@ export async function generateMetadata({
const { slug } = await params;
const item = await getBlogArticleBySlug(slug);
if (!item) return {};
return { title: item.titleFa || item.title, description: item.abstract || item.titleFa };
return { title: item.title, description: item.title };
}
export default async function BlogDetailPage({
@@ -46,7 +51,7 @@ export default async function BlogDetailPage({
<SiteHeader />
<main>
<NewsArticleDetail
item={toDisplayNewsItem(item)}
item={item}
basePath="/blog"
sectionLabel="بلاگ"
sectionLabelEn="BLOG"
+13 -48
View File
@@ -1,71 +1,36 @@
import type { Metadata } from "next";
import { NewsListing } from "@/components/news/NewsListing";
import { Editorial } from "@/components/shared/Editorial";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import heroImage from "@/assets/images/hero/hero-2.jpg";
import heroImageMobile from "@/assets/images/hero/hero-2-mobile.jpg";
import { blogListingPage, newsAndBlogPage } from "@/data/site";
import { getDemoNewsItems } from "@/data/news-demo";
import { blogListingPage } from "@/data/site";
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
export const metadata: Metadata = {
title: "بلاگ",
title: "مقالات",
description: "فهرست کامل مقالات منتشرشده‌ی گروه ساختمانی رئوفی.",
};
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export const revalidate = 300;
export default async function BlogListPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
export default async function BlogListPage() {
const posts = await getBlogs();
const blog = posts.filter((p) => p.type !== "news").map(toDisplayNewsItem);
const articles = posts.filter((post) => post.type !== "news");
const blog = articles.length ? articles.map(toDisplayNewsItem) : getDemoNewsItems("blog");
return (
<>
<SiteHeader />
<main>
<PageHero
image={heroImage}
imageMobile={heroImageMobile}
title={blogListingPage.hero.title}
breadcrumb={blogListingPage.hero.breadcrumb}
/>
<section className="bg-paper">
<Editorial
caption={blogListingPage.overview.caption}
captionLatin={blogListingPage.overview.captionLatin}
title={blogListingPage.overview.title}
body={blogListingPage.overview.body}
compact
/>
</section>
<section className="bg-[#f1efec] py-24 lg:py-32">
<NewsListing
items={blog}
page={page}
basePath="/blog"
emptyMessage={blogListingPage.emptyMessage}
/>
</section>
<PageCta
caption={newsAndBlogPage.cta.caption}
title={newsAndBlogPage.cta.title}
action={newsAndBlogPage.cta.action}
<NewsListing
items={blog}
basePath="/blog"
emptyMessage={blogListingPage.emptyMessage}
eyebrow="مقالات"
title="نگاهی عمیق‌تر به معماری و زندگی"
/>
</main>
<SiteFooter />
</>
);
+70 -88
View File
@@ -1,14 +1,9 @@
import type { Metadata } from "next";
import Link from "next/link";
import heroImage from "@/assets/images/hero/hero-3.jpg";
import heroImageMobile from "@/assets/images/hero/hero-3-mobile.jpg";
import { InstagramIcon, MailIcon, PhoneIcon, PinIcon } from "@/components/ContactIcons";
import { ContactForm } from "@/components/contact/ContactForm";
import { Reveal } from "@/components/Reveal";
import { Editorial } from "@/components/shared/Editorial";
import { PageHero } from "@/components/shared/PageHero";
import { SectionLabel } from "@/components/shared/SectionLabel";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { contact, contactPage } from "@/data/site";
@@ -26,98 +21,85 @@ export default function ContactPage() {
<>
<SiteHeader />
<main>
<PageHero
image={heroImage}
imageMobile={heroImageMobile}
title={contactPage.hero.title}
breadcrumb={contactPage.hero.breadcrumb}
/>
<section className="bg-paper">
<Editorial
caption={contactPage.overview.caption}
captionLatin={contactPage.overview.captionLatin}
title={contactPage.overview.title}
body={contactPage.overview.body}
compact
/>
</section>
<main className="contact-page">
<section className="bg-[#f1efec] py-24 lg:py-36">
<div className="mx-auto grid max-w-[1400px] px-5 lg:grid-cols-2 lg:px-10">
{/* Contact info + map */}
<Reveal className="bg-ink p-7 text-white lg:p-12">
<SectionLabel
fa={contactPage.info.caption}
en={contactPage.info.captionLatin}
tone="dark"
align="start"
className="mb-10"
/>
<div className="mx-auto max-w-[1400px] px-5 lg:px-10">
<div className="grid md:grid-cols-3">
<Reveal as="article" className="py-8 md:py-10 md:pe-8">
<div className="flex items-center gap-[5px] text-ink">
<PinIcon className="h-7 w-7 shrink-0" strokeWidth={1.8} />
<h2 className="text-xl font-bold text-ink">دفتر مرکزی</h2>
</div>
<p className="mt-4 max-w-sm leading-[2.1] text-muted">{contact.address}</p>
<a
href={MAP_EMBED_SRC}
target="_blank"
rel="noreferrer"
className="mt-6 inline-block border-b border-ink pb-1 text-sm font-bold text-ink transition-colors hover:border-ink/50 hover:text-ink/60"
>
مشاهده موقعیت روی نقشه
</a>
</Reveal>
<ul className="space-y-6">
<li className="flex gap-4">
<PinIcon className="mt-1 h-5 w-5 shrink-0 text-gold" />
<p className="leading-[2] text-white/75">{contact.address}</p>
</li>
{contact.phones.map((phone, i) => (
<li key={phone}>
<Link
href={`tel:${contact.phoneLinks[i]}`}
className="group flex items-center gap-4 text-white/75 transition-colors duration-300 hover:text-gold"
>
<PhoneIcon className="h-5 w-5 shrink-0 text-gold" />
<span className="latin">{phone}</span>
<Reveal as="article" delay={70} className="border-y border-ink/15 py-8 md:border-x md:border-y-0 md:px-8 md:py-10">
<div className="flex items-center gap-[5px] text-ink">
<PhoneIcon className="h-7 w-7 shrink-0" strokeWidth={1.8} />
<h2 className="text-xl font-bold text-ink">تماس با ما</h2>
</div>
<ul className="mt-4 space-y-3">
{contact.phones.map((phone, i) => (
<li key={phone}>
<Link href={`tel:${contact.phoneLinks[i]}`} className="border-b border-transparent pb-1 text-muted transition-colors hover:border-ink/50 hover:text-ink">
{phone}
</Link>
</li>
))}
<li>
<Link href={`mailto:${contact.email}`} className="border-b border-transparent pb-1 text-muted transition-colors hover:border-ink/50 hover:text-ink">
{contact.email}
</Link>
</li>
))}
</ul>
</Reveal>
<li>
<Link
href={`mailto:${contact.email}`}
className="group flex items-center gap-4 text-white/75 transition-colors duration-300 hover:text-gold"
>
<MailIcon className="h-5 w-5 shrink-0 text-gold" />
<span className="latin">{contact.email}</span>
</Link>
</li>
<Reveal as="article" delay={140} className="py-8 md:ps-8 md:py-10">
<div className="flex items-center gap-[5px] text-ink">
<InstagramIcon className="h-7 w-7 shrink-0" strokeWidth={1.8} />
<h2 className="text-xl font-bold text-ink">شبکه‌های اجتماعی</h2>
</div>
<p className="mt-4 leading-[2.1] text-muted">برای آشنایی با تازه‌ترین خبرها و پروژه‌های رئوفی، ما را دنبال کنید.</p>
<Link href={contact.instagramUrl} className="mt-4 inline-block border-b border-ink pb-1 text-ink transition-colors hover:border-ink/50 hover:text-ink/60">
{contact.instagram}
</Link>
</Reveal>
</div>
<li>
<Link
href={contact.instagramUrl}
className="group flex items-center gap-4 text-white/75 transition-colors duration-300 hover:text-gold"
>
<InstagramIcon className="h-5 w-5 shrink-0 text-gold" />
<span className="latin">{contact.instagram}</span>
</Link>
</li>
</ul>
<div className="mt-12 aspect-[4/3] w-full overflow-hidden border border-white/15">
<iframe
src={MAP_EMBED_SRC}
title="نقشه‌ی موقعیت گروه ساختمانی رئوفی"
loading="lazy"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
className="h-full w-full grayscale-[35%]"
/>
</div>
<Reveal delay={100} className="mt-12 h-[320px] overflow-hidden bg-white sm:h-[420px] lg:mt-16 lg:h-[520px]">
<iframe
src={MAP_EMBED_SRC}
title="نقشه‌ی موقعیت گروه ساختمانی رئوفی"
loading="lazy"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
className="h-full w-full grayscale-[25%]"
/>
</Reveal>
</div>
</section>
{/* Form */}
<Reveal delay={90} className="bg-white p-7 lg:p-12">
<div>
<SectionLabel
fa={contactPage.form.caption}
en={contactPage.form.captionLatin}
align="start"
className="mb-8"
/>
<ContactForm />
</div>
<section className="bg-paper py-24 lg:py-36">
<div className="mx-auto grid max-w-[1400px] gap-12 px-5 lg:grid-cols-[0.8fr_1.2fr] lg:gap-24 lg:px-10">
<Reveal>
<h2 className="max-w-md text-[clamp(1.8rem,3vw,2.75rem)] leading-[1.5] font-bold text-ink">
{contactPage.form.title}
</h2>
<p className="mt-6 max-w-md leading-[2.1] text-muted">
اطلاعات خود را ثبت کنید تا همکاران ما در اولین فرصت با شما تماس بگیرند.
</p>
</Reveal>
<Reveal delay={90}>
<ContactForm />
</Reveal>
</div>
</section>
+5 -1
View File
@@ -98,6 +98,10 @@
unicode-bidi: isolate;
}
.contact-page .kicker {
color: var(--color-ink);
}
/* Thin gold rule that precedes every section kicker. */
.kicker {
display: inline-flex;
@@ -114,7 +118,7 @@
background: currentColor;
}
/* Rich text rendered from the portfolio API's mainTextHtml field. */
/* Rich text rendered from the projects API's mainTextHtml field. */
.cms-body {
max-width: 738px;
margin-inline: auto;
+18 -95
View File
@@ -1,16 +1,9 @@
import type { Metadata } from "next";
import Link from "next/link";
import heroImage from "@/assets/images/hero/hero-1.jpg";
import heroImageMobile from "@/assets/images/hero/hero-1-mobile.jpg";
import { Reveal } from "@/components/Reveal";
import { NewsCarousel } from "@/components/news/NewsCarousel";
import { Editorial } from "@/components/shared/Editorial";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SectionLabel } from "@/components/shared/SectionLabel";
import { NewsListing, type NewsListingItem } from "@/components/news/NewsListing";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getDemoNewsItems } from "@/data/news-demo";
import { newsAndBlogPage } from "@/data/site";
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
@@ -19,102 +12,32 @@ export const metadata: Metadata = {
description: "آخرین اخبار، رویدادها و مقالات گروه ساختمانی رئوفی.",
};
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export const revalidate = 300;
export default async function NewsAndBlogPage() {
const posts = await getBlogs();
const news = posts.filter((p) => p.type === "news").map(toDisplayNewsItem);
const blog = posts.filter((p) => p.type !== "news").map(toDisplayNewsItem);
const hasNews = news.length > 0;
const hasBlog = blog.length > 0;
const hasNothing = !hasNews && !hasBlog;
const items: NewsListingItem[] = posts.length
? posts.map((post) => ({
...toDisplayNewsItem(post),
href: `${post.type === "news" ? "/news" : "/blog"}/${post.slug}`,
}))
: getDemoNewsItems().map((post) => ({
...post,
href: `${post.kind === "news" ? "/news" : "/blog"}/${post.slug}`,
}));
return (
<>
<SiteHeader />
<main>
<PageHero
image={heroImage}
imageMobile={heroImageMobile}
title={newsAndBlogPage.hero.title}
breadcrumb={newsAndBlogPage.hero.breadcrumb}
/>
<section className="bg-paper">
<Editorial
caption={newsAndBlogPage.overview.caption}
captionLatin={newsAndBlogPage.overview.captionLatin}
title={newsAndBlogPage.overview.title}
body={newsAndBlogPage.overview.body}
compact
/>
</section>
{hasNothing && (
<section className="bg-[#f1efec] py-24 lg:py-32">
<p className="text-center text-muted">{newsAndBlogPage.emptyMessage}</p>
</section>
)}
{hasNews && (
<section className="bg-[#f1efec] py-24 lg:py-32">
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<SectionLabel
fa={newsAndBlogPage.news.caption}
en={newsAndBlogPage.news.captionLatin}
className="mb-10"
/>
</Reveal>
</div>
<NewsCarousel items={news} basePath="/news" />
<div className="mx-auto mt-10 max-w-[1400px] px-6 text-center lg:px-12">
<Link
href="/news"
className="inline-block border-b border-ink px-1 pb-2 font-bold text-ink transition-colors duration-300 hover:border-gold hover:text-gold"
>
{newsAndBlogPage.news.action}
</Link>
</div>
</section>
)}
{hasBlog && (
<section className={`py-24 lg:py-32 ${hasNews ? "bg-paper" : "bg-[#f1efec]"}`}>
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<SectionLabel
fa={newsAndBlogPage.blog.caption}
en={newsAndBlogPage.blog.captionLatin}
className="mb-10"
/>
</Reveal>
</div>
<NewsCarousel items={blog} basePath="/blog" />
<div className="mx-auto mt-10 max-w-[1400px] px-6 text-center lg:px-12">
<Link
href="/blog"
className="inline-block border-b border-ink px-1 pb-2 font-bold text-ink transition-colors duration-300 hover:border-gold hover:text-gold"
>
{newsAndBlogPage.blog.action}
</Link>
</div>
</section>
)}
<PageCta
caption={newsAndBlogPage.cta.caption}
title={newsAndBlogPage.cta.title}
action={newsAndBlogPage.cta.action}
<NewsListing
items={items}
basePath="/news"
emptyMessage={newsAndBlogPage.emptyMessage}
eyebrow="مقالات و اخبار"
title="روایت‌هایی پشت هر چشم‌انداز"
/>
</main>
<SiteFooter />
</>
);
+9 -4
View File
@@ -4,21 +4,26 @@ import { notFound } from "next/navigation";
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getDemoNewsItem, getDemoNewsItems } from "@/data/news-demo";
import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api";
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
async function getNewsBySlug(slug: string) {
const demoItem = getDemoNewsItem(slug, "news");
if (demoItem) return demoItem;
const item = await getBlogBySlug(slug);
if (!item || item.type !== "news") {
return null;
}
return item;
return toDisplayNewsItem(item);
}
export async function generateStaticParams() {
const slugs = await getBlogSlugs("news");
return slugs.map((slug) => ({ slug }));
return [...new Set([...slugs, ...getDemoNewsItems("news").map((item) => item.slug)])]
.map((slug) => ({ slug }));
}
export async function generateMetadata({
@@ -29,7 +34,7 @@ export async function generateMetadata({
const { slug } = await params;
const item = await getNewsBySlug(slug);
if (!item) return {};
return { title: item.titleFa || item.title, description: item.abstract || item.titleFa };
return { title: item.title, description: item.title };
}
export default async function NewsDetailPage({
@@ -46,7 +51,7 @@ export default async function NewsDetailPage({
<SiteHeader />
<main>
<NewsArticleDetail
item={toDisplayNewsItem(item)}
item={item}
basePath="/news"
sectionLabel="اخبار"
sectionLabelEn="NEWS"
+11 -47
View File
@@ -1,14 +1,10 @@
import type { Metadata } from "next";
import { NewsListing } from "@/components/news/NewsListing";
import { Editorial } from "@/components/shared/Editorial";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import heroImage from "@/assets/images/hero/hero-1.jpg";
import heroImageMobile from "@/assets/images/hero/hero-1-mobile.jpg";
import { newsAndBlogPage, newsListingPage } from "@/data/site";
import { getDemoNewsItems } from "@/data/news-demo";
import { newsListingPage } from "@/data/site";
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
export const metadata: Metadata = {
@@ -16,56 +12,24 @@ export const metadata: Metadata = {
description: "فهرست کامل اخبار و رویدادهای منتشرشده‌ی گروه ساختمانی رئوفی.",
};
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export const revalidate = 300;
export default async function NewsListPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
export default async function NewsListPage() {
const posts = await getBlogs("news");
const news = posts.map(toDisplayNewsItem);
const news = posts.length ? posts.map(toDisplayNewsItem) : getDemoNewsItems("news");
return (
<>
<SiteHeader />
<main>
<PageHero
image={heroImage}
imageMobile={heroImageMobile}
title={newsListingPage.hero.title}
breadcrumb={newsListingPage.hero.breadcrumb}
/>
<section className="bg-paper">
<Editorial
caption={newsListingPage.overview.caption}
captionLatin={newsListingPage.overview.captionLatin}
title={newsListingPage.overview.title}
body={newsListingPage.overview.body}
compact
/>
</section>
<section className="bg-[#f1efec] py-24 lg:py-32">
<NewsListing
items={news}
page={page}
basePath="/news"
emptyMessage={newsListingPage.emptyMessage}
/>
</section>
<PageCta
caption={newsAndBlogPage.cta.caption}
title={newsAndBlogPage.cta.title}
action={newsAndBlogPage.cta.action}
<NewsListing
items={news}
basePath="/news"
emptyMessage={newsListingPage.emptyMessage}
eyebrow="اخبار"
title="روایت‌هایی پشت هر چشم‌انداز"
/>
</main>
<SiteFooter />
</>
);
-25
View File
@@ -1,25 +0,0 @@
import type { Metadata } from "next";
import { RaoofiServices } from "@/components/services/RaoofiServices";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getRecentPortfolios, toDisplayProject } from "@/lib/api";
export const revalidate = 300;
export const metadata: Metadata = {
title: "خدمات ما",
description: "خدمات یکپارچه گروه ساختمانی رئوفی؛ از نخستین ایده و طراحی تا مدیریت، اجرا و تحویل نهایی.",
};
export default async function ServicesPage() {
const portfolios = await getRecentPortfolios(12);
return (
<>
<SiteHeader />
<RaoofiServices projects={portfolios.map(toDisplayProject)} />
<SiteFooter />
</>
);
}
-61
View File
@@ -1,61 +0,0 @@
import type { Metadata } from "next";
import heroImage from "@/assets/images/hero/hero-3.jpg";
import heroImageMobile from "@/assets/images/hero/hero-3-mobile.jpg";
import { PortfolioBrowser } from "@/components/portfolios/PortfolioBrowser";
import { Editorial } from "@/components/shared/Editorial";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { portfoliosPage } from "@/data/site";
import { deriveCategories, getPortfolios, toDisplayProject } from "@/lib/api";
export const metadata: Metadata = {
title: "نمونه کارها",
description: "نمونه‌کارهای منتشرشده‌ی گروه ساختمانی رئوفی، از پروژه‌های مسکونی تا برج‌های اداری-تجاری.",
};
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export default async function PortfoliosPage() {
const portfolios = await getPortfolios();
const categories = deriveCategories(portfolios);
const displayProjects = portfolios.map(toDisplayProject);
return (
<>
<SiteHeader />
<main>
<PageHero
image={heroImage}
imageMobile={heroImageMobile}
title={portfoliosPage.hero.title}
breadcrumb={portfoliosPage.hero.breadcrumb}
/>
<section className="bg-paper">
<Editorial
caption={portfoliosPage.overview.caption}
captionLatin={portfoliosPage.overview.captionLatin}
title={portfoliosPage.overview.title}
body={portfoliosPage.overview.body}
compact
/>
</section>
<section className="bg-[#f1efec] pt-24 lg:pt-32">
<PortfolioBrowser projects={displayProjects} categories={categories} />
</section>
<PageCta
caption={portfoliosPage.cta.caption}
title={portfoliosPage.cta.title}
action={portfoliosPage.cta.action}
/>
</main>
<SiteFooter />
</>
);
}
@@ -1,21 +1,21 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { ProjectGalleryLightbox } from "@/components/portfolios/ProjectGalleryLightbox";
import { ProjectVideo } from "@/components/portfolios/ProjectVideo";
import { ProjectGalleryLightbox } from "@/components/projects/ProjectGalleryLightbox";
import { ProjectVideo } from "@/components/projects/ProjectVideo";
import { Reveal } from "@/components/Reveal";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SectionLabel } from "@/components/shared/SectionLabel";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { portfoliosPage, projectVideos } from "@/data/site";
import { getPortfolioBySlug, getPortfolioSlugs, toDisplayProject } from "@/lib/api";
import { projectsPage, projectVideos } from "@/data/site";
import { getProjectByRouteSlug, getProjectSlugs, toDisplayProject } from "@/lib/api";
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export async function generateStaticParams() {
const slugs = await getPortfolioSlugs();
const slugs = await getProjectSlugs();
return slugs.map((slug) => ({ slug }));
}
@@ -25,7 +25,7 @@ export async function generateMetadata({
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const item = await getPortfolioBySlug(slug);
const item = await getProjectByRouteSlug(slug);
if (!item) return {};
return {
@@ -37,22 +37,24 @@ export async function generateMetadata({
/**
* Structurally inspired by binghatti.com's project pages (hero, in-page jump
* nav, gallery grid, closing CTA) — but scoped strictly to what the meshkee
* API actually returns for a portfolio item. Binghatti's page also carries
* API actually returns for a project item. Binghatti's page also carries
* location/map, floor plans, a brochure download and priced unit listings;
* none of those exist as data here, so none of those sections are built.
*/
export default async function PortfolioDetailPage({
export default async function ProjectDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const item = await getPortfolioBySlug(slug);
const item = await getProjectByRouteSlug(slug);
if (!item) notFound();
const project = toDisplayProject(item);
if (slug !== project.slug) redirect(`/projects/${project.slug}`);
const hasGallery = project.gallery.length > 0;
const videoUrl = projectVideos[project.slug];
const videoUrl = projectVideos[project.apiSlug];
const navItems = [
{ id: "overview", label: "معرفی پروژه" },
@@ -76,8 +78,8 @@ export default async function PortfolioDetailPage({
title={project.name}
breadcrumb={[
{ label: "صفحه اصلی", href: "/" },
{ label: "نمونه کارها", href: "/portfolios" },
{ label: project.name, href: `/portfolios/${project.slug}` },
{ label: "پروژه‌ها", href: "/projects" },
{ label: project.name, href: `/projects/${project.slug}` },
]}
/>
@@ -140,9 +142,9 @@ export default async function PortfolioDetailPage({
)}
<PageCta
caption={portfoliosPage.cta.caption}
title={portfoliosPage.cta.title}
action={portfoliosPage.cta.action}
caption={projectsPage.cta.caption}
title={projectsPage.cta.title}
action={projectsPage.cta.action}
/>
</main>
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { ProjectBrowser } from "@/components/projects/ProjectBrowser";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { deriveCategories, getProjects, toDisplayProject } from "@/lib/api";
export const metadata: Metadata = {
title: "پروژه‌ها",
description: "پروژه‌های گروه ساختمانی رئوفی، از ساختمان‌های مسکونی تا برج‌های اداری و تجاری.",
};
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
export default async function ProjectsPage() {
const projects = await getProjects();
const categories = deriveCategories(projects);
const displayProjects = projects.map(toDisplayProject);
return (
<>
<SiteHeader />
<main>
<section className="bg-ink">
<ProjectBrowser projects={displayProjects} categories={categories} />
</section>
</main>
<SiteFooter />
</>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { RaoofiHome } from "@/components/raoofi-home/RaoofiHome";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
export const metadata: Metadata = {
title: "رئوفی هوم",
description: "رئوفی هوم؛ خلق فضاهای شخصی، کمیاب و ماندگار با طراحی و اجرای یکپارچه.",
};
export default function RaoofiHomePage() {
return (
<>
<SiteHeader />
<RaoofiHome />
<SiteFooter />
</>
);
}
+26 -8
View File
@@ -1,25 +1,37 @@
/** Shared icon set for contact details — used by the footer and the contact page. */
export function PinIcon({ className = "mt-1 h-4 w-4 shrink-0 text-gold" }: { className?: string }) {
export function PinIcon({
className = "mt-1 h-4 w-4 shrink-0 text-gold",
strokeWidth = 1.4,
}: {
className?: string;
strokeWidth?: number;
}) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11Z"
stroke="currentColor"
strokeWidth="1.4"
strokeWidth={strokeWidth}
/>
<circle cx="12" cy="10" r="2.5" stroke="currentColor" strokeWidth="1.4" />
<circle cx="12" cy="10" r="2.5" stroke="currentColor" strokeWidth={strokeWidth} />
</svg>
);
}
export function PhoneIcon({ className = "h-4 w-4 shrink-0 text-gold" }: { className?: string }) {
export function PhoneIcon({
className = "h-4 w-4 shrink-0 text-gold",
strokeWidth = 1.4,
}: {
className?: string;
strokeWidth?: number;
}) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M6.5 3.5h3l1.5 4-2 1.5a12 12 0 0 0 6 6l1.5-2 4 1.5v3a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.5 5.7a2 2 0 0 1 2-2.2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeWidth={strokeWidth}
/>
</svg>
);
@@ -34,11 +46,17 @@ export function MailIcon({ className = "h-4 w-4 shrink-0 text-gold" }: { classNa
);
}
export function InstagramIcon({ className = "h-4 w-4 shrink-0 text-gold" }: { className?: string }) {
export function InstagramIcon({
className = "h-4 w-4 shrink-0 text-gold",
strokeWidth = 1.4,
}: {
className?: string;
strokeWidth?: number;
}) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" strokeWidth="1.4" />
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.4" />
<rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" strokeWidth={strokeWidth} />
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth={strokeWidth} />
<circle cx="17" cy="7" r="1.1" fill="currentColor" />
</svg>
);
+1 -1
View File
@@ -131,7 +131,7 @@ export function Hero() {
</Link>
<Link
href="/portfolios"
href="/projects"
className="rounded-full border border-white/35 px-8 py-3.5 text-white transition-colors duration-300 hover:border-white hover:bg-white/10"
>
مشاهده پروژه‌ها
+4 -4
View File
@@ -50,7 +50,7 @@ export function Projects({ projects }: { projects: DisplayProject[] }) {
<div className="flex flex-wrap items-end justify-between gap-8">
<div>
<Reveal>
<SectionLabel fa="نمونه کارها" en="PORTFOLIO" align="start" className="mb-6" />
<SectionLabel fa="پروژه‌ها" en="PROJECTS" align="start" className="mb-6" />
</Reveal>
<Reveal delay={90}>
<h2 className="text-[1.625rem] leading-[1.25] font-extrabold whitespace-nowrap text-white lg:text-[2.625rem]">
@@ -61,7 +61,7 @@ export function Projects({ projects }: { projects: DisplayProject[] }) {
<div className="flex items-center gap-4">
<Link
href="/portfolios"
href="/projects"
className="hidden border-b border-white/25 pb-2 text-sm text-white/75 transition-colors hover:border-gold hover:text-white sm:block"
>
مشاهده همه پروژه‌ها
@@ -105,7 +105,7 @@ export function Projects({ projects }: { projects: DisplayProject[] }) {
key={project.slug}
className="w-[78vw] shrink-0 snap-start sm:w-[46vw] lg:w-[30vw] xl:w-[23rem]"
>
<Link href={`/portfolios/${project.slug}`} className="group block">
<Link href={`/projects/${project.slug}`} className="group block">
<div className="relative aspect-[3/4] overflow-hidden bg-ink-3">
<Image
src={project.image}
@@ -147,7 +147,7 @@ export function Projects({ projects }: { projects: DisplayProject[] }) {
<div className="mx-auto mt-10 max-w-[1400px] px-5 sm:hidden lg:px-10">
<Link
href="/portfolios"
href="/projects"
className="block border border-white/25 py-3.5 text-center text-sm text-white transition-colors hover:border-gold hover:bg-gold"
>
مشاهده همه پروژه‌ها
+2 -2
View File
@@ -12,7 +12,7 @@ export function Services() {
<div className="flex flex-wrap items-end justify-between gap-8">
<div>
<Reveal>
<SectionLabel fa="خدمات ما" en="WHAT WE DO" align="start" className="mb-6" />
<SectionLabel fa="آتلیه رئوفی" en="WHAT WE DO" align="start" className="mb-6" />
</Reveal>
<Reveal delay={90}>
<h2 className="text-[1.625rem] leading-[1.25] font-extrabold whitespace-nowrap text-ink lg:text-[2.625rem]">
@@ -36,7 +36,7 @@ export function Services() {
return (
<Reveal as="li" key={service.slug} delay={(i % 3) * 90} className="bg-paper">
<Link
href={`/our-services#${service.slug}`}
href={`/atelier#${service.slug}`}
className="group relative block h-full overflow-hidden p-9 lg:p-11"
>
{/* Ink panel wipes upward on hover. */}
+4 -4
View File
@@ -19,10 +19,10 @@ export function SiteFooter() {
</p>
<Link
href={contact.instagramUrl}
className="mt-7 inline-flex h-11 w-11 items-center justify-center rounded-full border border-white/20 transition-colors duration-300 hover:border-gold hover:bg-gold"
className="group mt-7 inline-flex h-11 w-11 items-center justify-center rounded-full border border-white transition-colors duration-300 hover:border-gold hover:bg-gold"
aria-label={`اینستاگرام ${site.name}`}
>
<InstagramIcon />
<InstagramIcon className="h-5 w-5 shrink-0 text-gold transition-colors duration-300 group-hover:text-white" />
</Link>
</div>
@@ -74,7 +74,7 @@ export function SiteFooter() {
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
>
<InstagramIcon />
<span className="latin">{contact.instagram}</span>
<span>{contact.instagram}</span>
</Link>
</li>
<li>
@@ -83,7 +83,7 @@ export function SiteFooter() {
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
>
<MailIcon />
<span className="latin">{contact.email}</span>
<span>{contact.email}</span>
</Link>
</li>
</ul>
+1 -1
View File
@@ -100,7 +100,7 @@ export function RaoofiAbout({ projects: _projects }: { projects: DisplayProject[
<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>}
{project.slug && <Link href={`/projects/${project.slug}`}>مشاهدهٔ پروژه</Link>}
</div>
</article>
</SwiperSlide>
+45 -95
View File
@@ -1,127 +1,77 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useEffect, useRef } from "react";
import type { Swiper as SwiperInstance } from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import { serviceDetails, services } from "@/data/site";
import { EditorialCardCarousel } from "@/components/shared/EditorialCardCarousel";
import type { DisplayProject } from "@/lib/api";
import "./raoofi-atelier.css";
const media = (name: string) => `/raoofi-media/atelier/${name}`;
const base = "/raoofi-media/atelier";
const serviceImages = Array.from({ length: 6 }, (_, index) => `${base}/service-${(index % 5) + 1}.jpg`);
const fallbackProjects = [
["فضایی برای زندگی معاصر", "portfolio-1.webp"], ["جزئیاتی فراتر از انتظار", "portfolio-2.webp"],
["آرامش در قلب معماری", "portfolio-3.webp"], ["تعادل فرم و عملکرد", "portfolio-4.jpg"],
["روایتی تازه از کیفیت", "portfolio-5.jpg"], ["خانه‌ای با هویت مستقل", "portfolio-6.webp"],
["ساخته‌شده برای ماندن", "portfolio-7.webp"],
].map(([name, file], index) => ({ slug: "", name, type: `پروژه ${index + 1}`, image: `${base}/${file}` }));
const collection = [
{ image: media("collection-1.webp"), title: "اقامتگاهی فراتر از تعریف", subtitle: "فضایی برای یک زندگی یگانه" },
{ image: media("collection-2.webp"), title: "خانه‌ای در اوج آسمان", subtitle: "روایتی شخصی از آرامش" },
{ image: media("collection-3.webp"), title: "شاهکاری برای یک نفر", subtitle: "طراحی‌شده برای ماندن" },
];
export function RaoofiAtelier() {
const galleryRef = useRef<SwiperInstance | null>(null);
const [activeSlide, setActiveSlide] = useState(0);
export function RaoofiAtelier({ projects }: { projects: DisplayProject[] }) {
const projectSwiper = useRef<SwiperInstance | null>(null);
const portfolio = projects.length
? projects.slice(0, 7).map((project) => ({ slug: project.slug, name: project.name, type: project.type, image: project.image }))
: fallbackProjects;
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => entries.forEach((entry) => entry.isIntersecting && entry.target.classList.add("is-visible")),
{ threshold: 0.12 },
);
document.querySelectorAll(".atelier-reveal").forEach((element) => observer.observe(element));
document.querySelectorAll(".osa-reveal").forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<main className="atelier-page" dir="rtl">
<section className="atelier-hero">
<video autoPlay muted loop playsInline preload="metadata" poster={media("hero-poster.jpg")}>
<source src={media("hero-mobile.mp4")} media="(max-width:700px)" type="video/mp4" />
<source src={media("hero-desktop.mp4")} type="video/mp4" />
</video>
<div className="atelier-shade" />
<div className="atelier-hero__brand">
<strong>آتلیه رئوفی</strong>
<span>هنرِ خلق یک زندگی منحصربه‌فرد</span>
</div>
<a className="atelier-hero__link" href="#collection">کشف مجموعه</a>
<span className="atelier-scroll" aria-hidden="true">⌄</span>
<main className="osa-page" dir="rtl">
<section className="osa-hero">
<video autoPlay muted loop playsInline preload="metadata"><source src={`${base}/hero-desktop.mp4`} type="video/mp4" /></video>
<div className="osa-overlay" />
<div className="osa-hero-mark"><strong>RAOOFI</strong><span>Atelier</span></div>
<span className="osa-scroll">⌄</span>
</section>
<section className="atelier-intro atelier-reveal">
<span>آتلیه رئوفی</span>
<h1>همکاری در بالاترین سطح</h1>
<div>
<p>آتلیه رئوفی برای کسانی شکل گرفته است که معیارهای زندگی خود را تعریف می‌کنند و می‌خواهند چشم‌انداز شخصی‌شان در تمام جزئیات فضا دیده شود.</p>
<p>این مسیر با همراهی معماران، طراحان و استادکاران برجسته، کمیاب‌ترین ایده‌ها را به آثاری دقیق و ماندگار تبدیل می‌کند.</p>
<p>ریشه در هنر، مهندسی و ساخت سفارشی دارد؛ تعریفی امروزی از کیفیت که عمیقاً شخصی و همواره یگانه است.</p>
<section className="osa-overview">
<div className="osa-heading osa-reveal"><span>نگاه کلی</span><h1>چشم‌انداز شما را<br />به واقعیت تبدیل می‌کنیم</h1><div><p>هنر و تجربهٔ گروه رئوفی را به طراحی، تأمین، مدیریت و اجرای کامل پروژه پیوند می‌دهیم تا هر فضا بازتابی دقیق از سبک زندگی و خواسته‌های شما باشد.</p><p>با همراهی معماران، هنرمندان و متخصصان برجسته، فضاهایی منحصربه‌فرد خلق می‌کنیم که زیبایی، عملکرد و کیفیت ماندگار را در کنار هم قرار می‌دهند.</p></div></div>
<div className="osa-collage osa-reveal"><div className="osa-collage-main"><Image src={`${base}/overview-1.webp`} alt="طراحی داخلی اختصاصی" fill sizes="65vw" /></div><div className="osa-collage-side"><Image src={`${base}/overview-2.jpg`} alt="فضای داخلی لوکس" fill sizes="35vw" /></div><div className="osa-sketch" aria-hidden="true">خطوط، نور، بافت<br />و جزئیات دقیق</div></div>
</section>
<section className="osa-services">
<div className="osa-heading osa-reveal"><span>خدمات</span><h2>از ایده تا تکمیل</h2><div><p>هر پروژه با دیدگاهی منحصربه‌فرد آغاز می‌شود. از نخستین جرقهٔ ایده تا اجرای نهایی، در تمام مسیر کنار شما هستیم.</p><p>می‌توانید مسیر کامل طراحی و اجرا یا هر یک از خدمات تخصصی را متناسب با نیاز پروژه انتخاب کنید.</p></div></div>
<div className="osa-reveal">
<EditorialCardCarousel
items={services.map((service, index) => ({ image: serviceImages[index], title: service.name, text: serviceDetails[service.slug].blurb }))}
previousLabel="خدمت قبلی"
nextLabel="خدمت بعدی"
/>
</div>
</section>
<section className="atelier-wide-image atelier-reveal">
<Image src={media("story-1.webp")} alt="فضای معماری اختصاصی" fill sizes="100vw" />
</section>
<section className="atelier-intro atelier-intro--second atelier-reveal">
<span>خلق منحصربه‌فرد</span>
<h2>آثاری که تنها یک‌بار ساخته می‌شوند</h2>
<div>
<p>هر پروژه پاسخی اختصاصی به شیوه زندگی، خواسته‌ها و رویاهای صاحب آن است؛ اثری که از تعریف‌های معمول تجمل و شکوه فراتر می‌رود.</p>
<p>از معماری و طراحی داخلی تا انتخاب هنر، متریال و جزئیات نهایی، همه‌چیز برای شکل‌دادن به یک جهان کاملاً شخصی کنار هم قرار می‌گیرد.</p>
<p>نتیجه، مجموعه‌ای محدود از فضاهای کمیاب است که مشابهی ندارند و با گذر زمان ارزشمندتر می‌شوند.</p>
</div>
</section>
<section className="atelier-editorial">
<div className="atelier-editorial__pair atelier-reveal">
<div className="atelier-editorial__image"><Image src={media("story-2.webp")} alt="جزئیات معماری لوکس" fill sizes="50vw" /></div>
<div className="atelier-editorial__image"><Image src={media("story-3.jpg")} alt="خانه‌ای با طراحی سفارشی" fill sizes="50vw" /></div>
</div>
<div className="atelier-editorial__copy atelier-reveal">
<p>مسیر آتلیه رئوفی با یک چشم‌انداز روشن آغاز شد: جابه‌جایی معیارهای بازار از طریق پروژه‌هایی که در طراحی، کیفیت ساخت و تجربه زندگی پیشگام هستند.</p>
</div>
<div className="atelier-editorial__feature atelier-reveal">
<div className="atelier-editorial__image"><Image src={media("story-4.webp")} alt="فضای داخلی معاصر" fill sizes="62vw" /></div>
<div className="atelier-editorial__copy"><p>هر اثر تازه، استاندارد قبلی را پشت سر می‌گذارد. پیوستگی میان ایده، طراحی و اجرا باعث می‌شود هر خانه هویتی مستقل و کیفیتی تکرارنشدنی پیدا کند.</p></div>
</div>
</section>
<section id="collection" className="atelier-collection">
<header className="atelier-section-title atelier-reveal">
<span>مجموعه آتلیه</span>
<h2>فصلی تازه از یک زندگی استثنایی</h2>
</header>
<div className="atelier-gallery atelier-reveal">
<Swiper
dir="ltr"
loop
speed={850}
slidesPerView={1}
grabCursor
onSwiper={(swiper) => { galleryRef.current = swiper; setActiveSlide(swiper.realIndex); }}
onSlideChange={(swiper) => setActiveSlide(swiper.realIndex)}
>
{collection.map((item) => (
<SwiperSlide key={item.title}>
<article dir="rtl">
<Image src={item.image} alt={item.title} fill sizes="100vw" />
<div className="atelier-shade" />
<div className="atelier-gallery__caption"><small>{item.subtitle}</small><h3>{item.title}</h3></div>
</article>
</SwiperSlide>
))}
<section className="osa-portfolio">
<div className="osa-heading osa-reveal"><span>نمونه پروژه‌ها</span><h2>از طرح تا واقعیت</h2><div><p>پروژه‌های ما گسترهٔ خدمات، دقت اجرا و کیفیتی را نشان می‌دهند که از یک مسیر یکپارچهٔ طراحی و مدیریت شکل گرفته است.</p></div></div>
<div className="osa-project-stage osa-reveal">
<Swiper dir="ltr" loop={portfolio.length > 1} speed={850} slidesPerView={1} grabCursor onSwiper={(swiper) => { projectSwiper.current = swiper; }}>
{portfolio.map((project, index) => <SwiperSlide key={`${project.name}-image-${index}`}><article dir="rtl"><Image src={project.image} alt={project.name} fill sizes="100vw" unoptimized={project.image.startsWith("http")} /><div className="osa-overlay" /><div className="osa-project-caption"><small>{project.type}</small><h3>{project.name}</h3>{project.slug && <Link href={`/projects/${project.slug}`}>مشاهده پروژه</Link>}</div></article></SwiperSlide>)}
</Swiper>
<div className="atelier-gallery__progress" aria-hidden="true">
{collection.map((item, index) => <button key={item.title} type="button" className={activeSlide === index ? "is-active" : ""} onClick={() => galleryRef.current?.slideToLoop(index)} />)}
</div>
<div className="atelier-gallery__buttons">
<button type="button" onClick={() => galleryRef.current?.slidePrev()} aria-label="اثر قبلی">→</button>
<button type="button" onClick={() => galleryRef.current?.slideNext()} aria-label="اثر بعدی">←</button>
</div>
<div className="osa-arrows osa-project-arrows"><button type="button" onClick={() => projectSwiper.current?.slidePrev()} aria-label="پروژه قبلی">←</button><button type="button" onClick={() => projectSwiper.current?.slideNext()} aria-label="پروژه بعدی">→</button></div>
</div>
</section>
<section className="atelier-manifesto atelier-reveal">
<p>شوقِ جان‌بخشیدن به رویاها و چشم‌اندازهای کسانی که آینده را می‌سازند، تعهد استوار آتلیه رئوفی را به تصور امر خارق‌العاده و تبدیل آن به واقعیت شکل می‌دهد.</p>
<p>آثار منحصربه‌فرد و معیارساز آتلیه رئوفی، خانه‌ها و پناهگاه‌هایی برای اندیشمندان، رهبران و سازندگان فرداست؛ فضاهایی برای کسانی که جسارت متفاوت‌بودن دارند.</p>
</section>
<section className="osa-cta"><video autoPlay muted loop playsInline preload="metadata"><source src="/raoofi-media/about/cta-mobile.mp4" media="(max-width:700px)" type="video/mp4" /><source src="/raoofi-media/about/cta-desktop.mp4" type="video/mp4" /></video><div className="osa-overlay" /><div className="osa-cta-copy osa-reveal"><h2>فضایی منحصربه‌فرد کشف کنید</h2><p>برای آغاز پروژه و دریافت مشاوره با ما گفت‌وگو کنید.</p><Link href="/contactus">درخواست مشاوره</Link></div></section>
</main>
);
}
+9 -10
View File
@@ -1,10 +1,9 @@
.atelier-page{overflow-x:clip;background:#f4f3f0;color:#10183b}.atelier-page h1,.atelier-page h2,.atelier-page h3,.atelier-page p{margin:0}.atelier-shade{position:absolute;inset:0;background:linear-gradient(0deg,rgba(5,8,15,.5),rgba(5,8,15,.04) 62%)}
.atelier-hero{position:relative;height:100svh;min-height:650px;overflow:hidden;background:#111}.atelier-hero video{width:100%;height:100%;object-fit:cover}.atelier-hero__brand{position:absolute;z-index:2;top:49%;right:0;left:0;display:flex;align-items:center;flex-direction:column;padding:30px 20px;background:rgba(7,10,15,.58);backdrop-filter:blur(16px);color:#fff;text-align:center}.atelier-hero__brand strong{font-size:clamp(34px,4vw,58px);font-weight:700;line-height:1.35}.atelier-hero__brand span{margin-top:12px;font-size:13px;letter-spacing:.08em}.atelier-hero__link{position:absolute;z-index:2;top:112px;left:50%;transform:translateX(-50%);border-bottom:1px solid rgba(255,255,255,.75);padding-bottom:6px;color:#fff;font-size:12px}.atelier-scroll{position:absolute;z-index:2;bottom:22px;left:50%;color:#fff;font-size:32px;animation:atelier-bob 1.8s ease-in-out infinite}
.atelier-intro{width:min(1040px,86%);margin:auto;padding:150px 0;text-align:center}.atelier-intro>span,.atelier-section-title>span{display:block;color:#9b8668;font-size:12px;letter-spacing:.08em}.atelier-intro h1,.atelier-intro h2,.atelier-section-title h2{margin:28px auto 45px;font-size:clamp(28px,3.2vw,46px);font-weight:700;line-height:1.45}.atelier-intro>div{display:grid;gap:34px;font-size:clamp(15px,1.25vw,19px);line-height:2}.atelier-intro--second{padding-top:165px}
.atelier-wide-image{position:relative;height:min(68vw,850px);overflow:hidden;background:#ddd}.atelier-wide-image img,.atelier-editorial img{object-fit:cover}
.atelier-editorial{padding:0 4vw 150px}.atelier-editorial__pair{display:grid;grid-template-columns:1fr 1fr;gap:3vw}.atelier-editorial__image{position:relative;height:46vw;max-height:720px;overflow:hidden}.atelier-editorial__copy{width:min(760px,86%);margin:90px auto;font-size:clamp(17px,1.7vw,24px);font-weight:500;line-height:2;text-align:center}.atelier-editorial__feature{display:grid;grid-template-columns:1.35fr .65fr;align-items:stretch;gap:0;background:#fff}.atelier-editorial__feature .atelier-editorial__image{height:48vw;max-height:760px}.atelier-editorial__feature .atelier-editorial__copy{display:flex;align-items:center;width:auto;margin:0;padding:8vw 5vw;font-size:clamp(16px,1.45vw,21px);text-align:right}
.atelier-collection{padding:145px 0 0;background:#fff}.atelier-section-title{width:min(980px,88%);margin:0 auto 90px;text-align:center}.atelier-gallery{position:relative;height:min(72vw,850px);overflow:hidden;background:#111}.atelier-gallery .swiper,.atelier-gallery .swiper-wrapper,.atelier-gallery .swiper-slide,.atelier-gallery article{height:100%}.atelier-gallery article{position:relative}.atelier-gallery article>img{object-fit:cover}.atelier-gallery__caption{position:absolute;z-index:2;right:7%;bottom:13%;color:#fff}.atelier-gallery__caption small{font-size:13px}.atelier-gallery__caption h3{max-width:700px;margin-top:12px;color:#fff;font-size:clamp(28px,3.4vw,46px);font-weight:700}.atelier-gallery__progress{position:absolute;z-index:4;right:7%;bottom:6%;left:22%;display:flex;direction:ltr}.atelier-gallery__progress button{height:2px;flex:1;border:0;background:rgba(255,255,255,.35);padding:0}.atelier-gallery__progress button.is-active{background:#fff}.atelier-gallery__buttons{position:absolute;z-index:5;bottom:3%;left:5%;display:flex;gap:12px}.atelier-gallery__buttons button{display:grid;width:50px;height:50px;place-items:center;border:1px solid rgba(255,255,255,.75);border-radius:50%;background:transparent;color:#fff;font-size:21px;cursor:pointer}.atelier-gallery__buttons button:hover{background:#fff;color:#10183b}
.atelier-manifesto{display:grid;gap:30px;width:min(820px,calc(100% - 40px));margin:auto;padding:105px 0 115px;color:#10183b;font-size:clamp(15px,1.15vw,18px);font-weight:500;line-height:1.9;text-align:center}
.atelier-reveal{opacity:0;transform:translateY(34px);transition:opacity .9s cubic-bezier(.22,1,.36,1),transform .9s cubic-bezier(.22,1,.36,1)}.atelier-reveal.is-visible{opacity:1;transform:none}@keyframes atelier-bob{50%{transform:translateY(8px)}}
@media(max-width:700px){.atelier-hero{min-height:620px}.atelier-hero__brand strong{font-size:34px}.atelier-hero__brand span{max-width:250px;font-size:11px}.atelier-hero__link{top:88px}.atelier-intro{width:calc(100% - 40px);padding:100px 0}.atelier-intro h1,.atelier-intro h2,.atelier-section-title h2{margin:24px auto 34px;font-size:clamp(28px,8vw,38px)}.atelier-intro>div{gap:26px;font-size:14px}.atelier-wide-image{height:120vw}.atelier-editorial{padding:0 18px 100px}.atelier-editorial__pair{grid-template-columns:1fr;gap:18px}.atelier-editorial__image{height:110vw}.atelier-editorial__pair>div:nth-child(2){height:80vw}.atelier-editorial__copy{margin:65px auto;font-size:17px}.atelier-editorial__feature{grid-template-columns:1fr}.atelier-editorial__feature .atelier-editorial__image{height:110vw}.atelier-editorial__feature .atelier-editorial__copy{padding:55px 25px}.atelier-collection{padding-top:100px}.atelier-section-title{margin-bottom:60px}.atelier-gallery{height:76svh;min-height:570px}.atelier-gallery__caption{right:24px;left:24px;bottom:18%}.atelier-gallery__caption h3{font-size:30px}.atelier-gallery__progress{right:22px;bottom:8%;left:140px}.atelier-gallery__buttons{bottom:5%;left:20px}.atelier-manifesto{gap:24px;padding:75px 0 85px;font-size:14px;line-height:2}}
@media(prefers-reduced-motion:reduce){.atelier-reveal{opacity:1;transform:none}.atelier-scroll{animation:none}}
.osa-page{overflow-x:clip;background:#f5f4f1;color:#10183b}.osa-page h1,.osa-page h2,.osa-page h3,.osa-page p{margin:0}.osa-overlay{position:absolute;inset:0;background:linear-gradient(0deg,rgba(7,10,16,.48),rgba(7,10,16,.06) 62%)}
.osa-hero{position:relative;height:100vh;height:100svh;min-height:650px;overflow:hidden;background:#111}.osa-hero video{width:100%;height:100%;object-fit:cover}.osa-hero-mark{position:absolute;z-index:2;top:48%;left:50%;display:flex;width:100%;transform:translate(-50%,-50%);flex-direction:column;align-items:center;padding:30px 0;background:rgba(7,10,15,.55);backdrop-filter:blur(16px);color:#fff}.osa-hero-mark strong{font-family:Arial,sans-serif;font-size:clamp(34px,4vw,58px);font-weight:700;letter-spacing:.22em}.osa-hero-mark span{margin-top:-4px;font-family:Georgia,serif;font-size:clamp(22px,2.2vw,34px);font-weight:700;font-style:italic}.osa-scroll{position:absolute;z-index:2;bottom:28px;left:50%;color:#fff;font-size:32px;animation:osa-bob 1.8s ease-in-out infinite}
.osa-overview,.osa-services,.osa-portfolio{padding-top:150px}.osa-heading{width:min(1120px,86%);margin:0 auto 130px;text-align:center}.osa-heading>span{display:block;color:#9b8668;font-size:12px;letter-spacing:.08em}.osa-heading h1,.osa-heading h2{margin:30px auto 42px;font-size:clamp(28px,3.2vw,46px);font-weight:700;line-height:1.4}.osa-heading>div{display:grid;gap:34px;max-width:960px;margin:auto;font-size:clamp(15px,1.35vw,20px);line-height:2}.osa-collage{display:grid;grid-template-columns:1.05fr .95fr;grid-template-rows:450px 260px;gap:14px;padding:0 2vw 150px}.osa-collage-main,.osa-collage-side{position:relative;overflow:hidden}.osa-collage-main{grid-row:1/3}.osa-collage-side{grid-column:2}.osa-collage img{object-fit:cover;transition:transform 1s}.osa-collage:hover img{transform:scale(1.025)}.osa-sketch{display:flex;align-items:center;justify-content:center;background:#fff;color:#9b8668;font-size:clamp(20px,2vw,30px);font-weight:700;line-height:1.7;text-align:center}
.osa-services{padding-bottom:145px;background:#f0efec}.osa-arrows{position:absolute;z-index:5;right:5vw;bottom:18px;display:flex;direction:ltr;gap:18px}.osa-arrows button{border:0;background:transparent;color:#10183b;font-size:35px;cursor:pointer;transition:color .25s}.osa-arrows button:hover{color:#9b8668}
.osa-portfolio{padding-bottom:150px;background:#fff}.osa-project-nav{display:flex;gap:42px;width:92%;margin:0 auto 55px;padding-bottom:16px;overflow-x:auto;scrollbar-width:none;direction:rtl}.osa-project-nav::-webkit-scrollbar{display:none}.osa-project-nav button{flex:none;border:0;border-bottom:1px solid transparent;background:transparent;padding:0 0 12px;color:#8b8d94;font-size:17px;cursor:pointer}.osa-project-nav button.is-active{border-color:#10183b;color:#10183b}.osa-project-stage{position:relative;width:94%;height:min(68vw,780px);margin:auto;overflow:hidden;background:#111}.osa-project-stage,.osa-project-stage .swiper,.osa-project-stage .swiper-wrapper,.osa-project-stage .swiper-slide,.osa-project-stage article{height:min(68vw,780px)}.osa-project-stage article{position:relative}.osa-project-stage img{object-fit:cover}.osa-project-caption{position:absolute;z-index:2;right:6%;bottom:10%;color:#fff}.osa-project-caption h3{max-width:720px;margin:10px 0 20px;color:#fff;font-size:clamp(28px,3.4vw,46px);font-weight:700}.osa-project-caption a{border-bottom:1px solid #fff;padding-bottom:7px;color:#fff}.osa-project-arrows{right:auto;bottom:6%;left:4%;}.osa-project-arrows button{display:grid;width:52px;height:52px;place-items:center;border:1px solid rgba(255,255,255,.75);border-radius:50%;color:#fff;font-size:22px}.osa-project-arrows button:hover{background:#fff;color:#10183b}
.osa-cta{position:relative;height:650px;margin:0 5vw 80px;overflow:hidden;background:#111}.osa-cta video{width:100%;height:100%;object-fit:cover}.osa-cta-copy{position:absolute;z-index:2;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:25px;color:#fff;text-align:center}.osa-cta-copy h2{color:#fff;font-size:clamp(30px,3.2vw,46px);font-weight:700}.osa-cta-copy p{margin:12px 0 35px}.osa-cta-copy a{min-width:220px;border:1px solid #fff;padding:13px 25px;color:#fff}.osa-reveal{opacity:0;transform:translateY(34px);transition:opacity .9s cubic-bezier(.22,1,.36,1),transform .9s cubic-bezier(.22,1,.36,1)}.osa-reveal.is-visible{opacity:1;transform:none}
@keyframes osa-bob{50%{transform:translateY(8px)}}
@media(max-width:700px){.osa-hero{min-height:620px}.osa-hero-mark strong{font-size:34px}.osa-overview,.osa-services,.osa-portfolio{padding-top:95px}.osa-heading{width:calc(100% - 40px);margin-bottom:75px}.osa-heading h1,.osa-heading h2{margin:25px auto 35px;font-size:clamp(28px,8vw,38px)}.osa-heading>div{gap:25px;font-size:14px}.osa-collage{grid-template-columns:1fr;grid-template-rows:115vw 72vw 45vw;padding:0 18px 95px}.osa-collage-main{grid-row:auto}.osa-collage-side{grid-column:auto}.osa-arrows{right:18px;bottom:15px}.osa-project-nav{gap:26px;margin-bottom:35px}.osa-project-nav button{font-size:14px}.osa-project-stage,.osa-project-stage .swiper,.osa-project-stage .swiper-wrapper,.osa-project-stage .swiper-slide,.osa-project-stage article{height:72svh;min-height:570px}.osa-project-stage{width:100%}.osa-project-caption{right:24px;left:24px;bottom:18%}.osa-project-caption h3{font-size:30px}.osa-project-arrows{bottom:5%;left:22px}.osa-cta{height:560px;margin:0}.osa-cta-copy h2{font-size:34px}}
@media(prefers-reduced-motion:reduce){.osa-reveal{opacity:1;transform:none}.osa-scroll{animation:none}}
+3 -3
View File
@@ -46,7 +46,7 @@ function validate(form: FormState): Errors {
}
const fieldClass =
"w-full border-0 border-b border-line bg-transparent px-0 py-3 text-ink placeholder:text-muted/60 transition-colors duration-300 focus:border-gold focus:outline-none";
"w-full border-0 border-b border-line bg-transparent px-0 py-3 font-sans text-ink placeholder:text-muted/60 transition-colors duration-300 focus:border-ink focus:outline-none";
export function ContactForm() {
const [form, setForm] = useState<FormState>(EMPTY);
@@ -135,7 +135,7 @@ export function ContactForm() {
onChange={update("phone")}
placeholder={contactPage.form.phonePlaceholder}
aria-invalid={!!errors.phone}
className={`${fieldClass} latin text-end`}
className={`${fieldClass} text-end`}
/>
{errors.phone && <p className="mt-1.5 text-xs text-red-600">{errors.phone}</p>}
</div>
@@ -153,7 +153,7 @@ export function ContactForm() {
onChange={update("email")}
placeholder={contactPage.form.emailPlaceholder}
aria-invalid={!!errors.email}
className={`${fieldClass} latin text-end`}
className={`${fieldClass} text-end`}
/>
{errors.email && <p className="mt-1.5 text-xs text-red-600">{errors.email}</p>}
</div>
+4 -4
View File
@@ -36,9 +36,9 @@ const partners = [
const menu = [
{ label: "صفحه اصلی", href: "/" },
{ label: "پروژه‌ها", href: "/portfolios" },
{ label: "خدمات ما", href: "/our-services" },
{ label: "پروژه‌ها", href: "/projects" },
{ label: "آتلیه رئوفی", href: "/atelier" },
{ label: "رئوفی هوم", href: "/raoofi-home" },
{ label: "داستان ما", href: "/aboutus" },
{ label: "اخبار", href: "/news-and-blog" },
{ label: "تماس با ما", href: "/contactus" },
@@ -174,7 +174,7 @@ export function RaoofiHome() {
<div className="om-project-shade"/><div className="om-project-content om-reveal"><small>پروژه‌های رئوفی / {String(index + 1).padStart(2, "0")}</small><h3>{project.title}</h3><span>{project.subtitle}</span></div>
</article>)}</section>
<section className="om-retreat"><Image src={asset("project-4.webp")} alt="معماری مسکونی" fill sizes="100vw" /><div className="om-retreat-inner"><p className="om-eyebrow">مجموعه‌ای متمایز</p><h2>آرامشی در دلِ شهر</h2><ArrowLink href="/portfolios">کشف پروژه‌ها</ArrowLink></div></section>
<section className="om-retreat"><Image src={asset("project-4.webp")} alt="معماری مسکونی" fill sizes="100vw" /><div className="om-retreat-inner"><p className="om-eyebrow">مجموعه‌ای متمایز</p><h2>آرامشی در دلِ شهر</h2><ArrowLink href="/projects">کشف پروژه‌ها</ArrowLink></div></section>
<section className="om-partners">
<div className="om-partners-intro om-container om-reveal"><p className="om-eyebrow">همکاران</p><h2>همراهی با صاحبان اندیشه</h2><p>خلاقیت بی‌مرز با پیشگامان این صنعت پیوند می‌خورد و روایت‌هایی تازه در طراحی و زندگی می‌آفریند.</p><a className="om-video-link" href="https://www.youtube.com/watch?v=fy8VlBj1pAU" target="_blank" rel="noreferrer">تماشای ویدیو ↗</a></div>
@@ -186,7 +186,7 @@ export function RaoofiHome() {
</div>
</section>
<section className="om-atelier"><Image src={asset("atelier.png")} alt="فضای طراحی" fill sizes="100vw" /><div className="om-atelier-card"><p className="om-eyebrow">آتلیه رئوفی</p><h2>تصورِ شما، هنرِ ما</h2><p>هر فضا با نگاهی شخصی آغاز می‌شود و با وسواس در جزئیات، به اثری ماندگار بدل می‌گردد.</p></div></section>
<section className="om-atelier"><Image src={asset("atelier.png")} alt="فضای طراحی" fill sizes="100vw" /><div className="om-atelier-card"><p className="om-eyebrow">رئوفی هوم</p><h2>تصورِ شما، هنرِ ما</h2><p>هر فضا با نگاهی شخصی آغاز می‌شود و با وسواس در جزئیات، به اثری ماندگار بدل می‌گردد.</p></div></section>
<section className="om-story om-container om-reveal">
<div className="om-story-intro">
+72 -73
View File
@@ -1,99 +1,98 @@
import { ProjectGalleryLightbox } from "@/components/portfolios/ProjectGalleryLightbox";
import Image from "next/image";
import Link from "next/link";
import { NewsShareActions } from "@/components/news/NewsShareActions";
import { Reveal } from "@/components/Reveal";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SectionLabel } from "@/components/shared/SectionLabel";
import { newsAndBlogPage } from "@/data/site";
import type { DisplayNewsItem } from "@/lib/api";
/**
* Shared detail template for a single news or blog item — same rules as
* NewsListing keeps the two archives consistent, this keeps their detail
* pages consistent too, and mirrors the portfolio detail page's own
* jump-nav + gallery pattern since blog items carry the same gallery field.
* Body content is the item's real mainTextHtml from the API; nothing here
* is invented, and the gallery section only appears once a post actually
* has images to show.
*/
export function NewsArticleDetail({
item,
basePath,
sectionLabel,
sectionLabelEn,
}: {
item: DisplayNewsItem;
basePath: string;
sectionLabel: string;
sectionLabelEn: string;
}) {
const hasGallery = item.gallery.length > 0;
const navItems = [
{ id: "overview", label: "معرفی" },
...(hasGallery ? [{ id: "gallery", label: "گالری تصاویر" }] : []),
];
return (
<>
<PageHero
image={item.image}
title={item.title}
breadcrumb={[
{ label: "صفحه اصلی", href: "/" },
{ label: "مقالات و اخبار", href: "/news-and-blog" },
{ label: sectionLabel, href: basePath },
{ label: item.title, href: `${basePath}/${item.slug}` },
]}
/>
<article className="bg-paper text-ink">
<header className="px-5 pb-20 pt-[125px] lg:px-12 lg:pb-24 lg:pt-[132px]">
<div className="mx-auto max-w-[1400px]">
<div className="flex items-center justify-between gap-5 text-[11px] text-[#9b896d] lg:text-[12px]">
<Link
href={basePath}
className="bg-[#f1efec] px-4 py-2 text-ink transition-colors duration-300 hover:bg-ink hover:text-white"
>
{sectionLabel}
</Link>
<time>{item.date}</time>
</div>
{navItems.length > 1 && (
<nav aria-label="بخش‌های صفحه" className="border-b border-line bg-[#f1efec]">
<ul className="no-scrollbar mx-auto flex max-w-[1400px] items-center justify-center gap-10 overflow-x-auto px-6 lg:px-12">
{navItems.map((navItem) => (
<li key={navItem.id}>
<a
href={`#${navItem.id}`}
className="block scroll-mt-24 border-b-2 border-transparent py-5 text-sm font-bold whitespace-nowrap text-ink transition-colors duration-300 hover:border-gold hover:text-gold"
>
{navItem.label}
</a>
</li>
))}
</ul>
</nav>
)}
<section id="overview" className="scroll-mt-20 bg-paper py-24 lg:py-36">
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<SectionLabel fa={sectionLabel} en={sectionLabelEn} className="mb-14" />
<h1 className="mx-auto mt-14 max-w-[1050px] text-center text-[clamp(16px,2.55vw,34px)] leading-[1.65] font-normal text-ink lg:mt-12">
{item.title}
</h1>
</Reveal>
{item.bodyHtml && (
<Reveal delay={90}>
<div className="cms-body cms-body--editorial" dangerouslySetInnerHTML={{ __html: item.bodyHtml }} />
</Reveal>
)}
</div>
</header>
<Reveal>
<div className="relative h-[36vh] min-h-[300px] w-full overflow-hidden lg:h-[44vh] lg:min-h-[430px]">
<Image
src={item.image}
alt={item.title}
fill
priority
quality={88}
sizes="100vw"
className="object-cover"
/>
</div>
</Reveal>
<section className="px-5 pb-24 pt-32 lg:px-12 lg:pb-36 lg:pt-[clamp(13rem,27vw,28rem)]">
<Reveal>
<div
className="news-article-body mx-auto max-w-[880px] text-[15px] leading-[2.25] text-ink lg:text-[17px]"
dangerouslySetInnerHTML={{ __html: item.bodyHtml }}
/>
</Reveal>
</section>
{hasGallery && (
<section id="gallery" className="scroll-mt-20 bg-[#f1efec] py-24 lg:py-32">
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<SectionLabel fa="گالری تصاویر" en="GALLERY" className="mb-14" />
{item.gallery.length > 0 && (
<section className="mx-auto grid max-w-[1400px] grid-cols-1 gap-8 px-5 pb-36 md:grid-cols-12 lg:gap-x-8 lg:gap-y-44 lg:px-12 lg:pb-48">
{item.gallery.slice(0, 3).map((image, index) => (
<Reveal
key={`${image}-${index}`}
className={
index === 0
? "md:col-span-8"
: index === 1
? "md:col-span-4"
: "md:col-span-6 md:col-start-7"
}
delay={index * 80}
>
<div className={`relative overflow-hidden ${index === 0 ? "aspect-[16/8]" : "aspect-[16/9]"}`}>
<Image
src={image}
alt={`${item.title} - تصویر ${index + 1}`}
fill
quality={82}
sizes={index === 0 ? "(max-width: 768px) 100vw, 66vw" : "(max-width: 768px) 100vw, 42vw"}
className="object-cover transition-transform duration-[1200ms] ease-out-soft hover:scale-[1.025]"
/>
</div>
</Reveal>
<ProjectGalleryLightbox images={item.gallery} alt={item.title} />
</div>
))}
</section>
)}
<PageCta
caption={newsAndBlogPage.cta.caption}
title={newsAndBlogPage.cta.title}
action={newsAndBlogPage.cta.action}
/>
</>
<section className="pb-24 text-center lg:pb-32">
<p className="mb-6 text-sm text-ink">اشتراک‌گذاری خبر</p>
<NewsShareActions title={item.title} />
</section>
</article>
);
}
+78 -63
View File
@@ -1,87 +1,102 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { Reveal } from "@/components/Reveal";
import { Pagination } from "@/components/shared/Pagination";
import type { DisplayNewsItem } from "@/lib/api";
const PAGE_SIZE = 9;
/**
* Grid + pagination for a full news or blog archive. Used by both /news and
* /blog so the two keep identical rules — only the item list, base path and
* empty-state copy differ between them.
*/
export type NewsListingItem = DisplayNewsItem & { href?: string };
export function NewsListing({
items,
page,
basePath,
emptyMessage,
eyebrow = "مقالات و اخبار",
title = "روایت‌هایی پشت هر چشم‌انداز",
}: {
items: DisplayNewsItem[];
page: number;
items: NewsListingItem[];
basePath: string;
emptyMessage: string;
eyebrow?: string;
title?: string;
}) {
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
const currentPage = Math.min(totalPages, Math.max(1, page));
const pageItems = items.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const visibleItems = items.slice(0, visibleCount);
const hasMore = visibleCount < items.length;
return (
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
{pageItems.length === 0 ? (
<p className="py-16 text-center text-muted">{emptyMessage}</p>
) : (
<ul className="grid gap-7 md:grid-cols-2 lg:gap-10">
{pageItems.map((item, i) => (
<Reveal as="li" key={item.slug} delay={(i % 6) * 70}>
<Link href={`${basePath}/${item.slug}`} className="group block h-full">
<article className="grid h-full bg-paper lg:grid-cols-[1.1fr_.9fr]">
<div className="relative aspect-[4/3] overflow-hidden lg:aspect-auto lg:min-h-[360px]">
<Image
src={item.image}
alt={item.title}
fill
quality={80}
sizes="(max-width: 768px) 100vw, 28vw"
loading={i < 3 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.07]"
/>
<div className="absolute inset-0 bg-ink/0 transition-colors duration-500 group-hover:bg-ink/20" />
</div>
<section className="bg-paper pb-24 pt-[148px] lg:pb-36 lg:pt-[190px]">
<div className="mx-auto max-w-[1400px] px-5 lg:px-12">
<header className="mb-20 text-center lg:mb-28">
<p className="text-[12px] font-bold tracking-[0.12em] text-[#9b896d]">{eyebrow}</p>
<h1 className="mx-auto mt-7 max-w-[1000px] text-[clamp(16px,2.5vw,33px)] leading-[1.65] font-normal text-ink">
{title}
</h1>
</header>
<div className="flex flex-1 flex-col justify-between p-7 lg:p-8">
<time className="flex items-center gap-2.5 text-[0.8rem] text-muted">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 text-gold">
<rect x="3.5" y="5" width="17" height="15.5" rx="2" stroke="currentColor" strokeWidth="1.4" />
<path d="M3.5 10h17M8 3.5V6M16 3.5V6" stroke="currentColor" strokeWidth="1.4" />
</svg>
{item.date}
</time>
{visibleItems.length === 0 ? (
<p className="py-16 text-center text-muted">{emptyMessage}</p>
) : (
<ul className="grid gap-x-4 gap-y-16 sm:grid-cols-2 lg:grid-cols-3 lg:gap-x-5 lg:gap-y-20">
{visibleItems.map((item, i) => {
const href = item.href ?? `${basePath}/${item.slug}`;
<h3 className="mt-4 text-[clamp(1.15rem,1.5vw,1.55rem)] leading-[1.7] font-bold text-ink transition-colors duration-300 group-hover:text-gold">
{item.title}
</h3>
return (
<Reveal as="li" key={`${href}-${item.slug}`} delay={(i % 6) * 55}>
<article className="group h-full">
<Link href={href} className="block">
<div className="relative aspect-[3/4] overflow-hidden bg-sand">
<Image
src={item.image}
alt={item.title}
fill
quality={82}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
priority={i < 3}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.045]"
/>
<div className="absolute inset-0 bg-ink/0 transition-colors duration-500 group-hover:bg-ink/10" />
</div>
</Link>
<span className="mt-8 flex w-max items-center gap-2 border-b border-ink/50 pb-1 text-sm text-muted transition-colors duration-300 group-hover:border-gold group-hover:text-gold">
ادامه مطلب
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
</svg>
</span>
</div>
</article>
</Link>
</Reveal>
))}
</ul>
)}
<div className="pt-4 text-right">
<time className="text-[11px] text-muted/75">{item.date}</time>
<h2 className="mt-1.5 min-h-[3.4em] text-[17px] leading-[1.7] font-medium text-ink">
<Link href={href} className="transition-colors duration-300 hover:text-muted">
{item.title}
</Link>
</h2>
<Pagination
page={currentPage}
totalPages={totalPages}
hrefFor={(p) => `${basePath}?page=${p}`}
/>
</div>
<Link
href={href}
className="mt-7 inline-flex min-w-[145px] items-center justify-center bg-[#696a68] px-6 py-3 text-[12px] text-white transition-colors duration-300 hover:bg-ink"
>
مشاهده خبر
</Link>
</div>
</article>
</Reveal>
);
})}
</ul>
)}
{hasMore && (
<div className="mt-20 flex justify-center lg:mt-28">
<button
type="button"
onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}
className="min-w-[260px] bg-[#f1efec] px-10 py-4 text-sm text-ink transition-colors duration-300 hover:bg-ink hover:text-white"
>
مشاهده بیشتر
</button>
</div>
)}
</div>
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
const buttonClass =
"grid h-9 w-9 place-items-center rounded-full border border-ink/10 text-ink transition-colors duration-300 hover:border-ink hover:bg-ink hover:text-white";
export function NewsShareActions({ title }: { title: string }) {
const [copied, setCopied] = useState(false);
const currentUrl = () => window.location.href;
const copyLink = async () => {
await navigator.clipboard.writeText(currentUrl());
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
};
const openShare = (service: "whatsapp" | "linkedin") => {
const url = encodeURIComponent(currentUrl());
const shareUrl = service === "whatsapp"
? `https://wa.me/?text=${url}`
: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`;
window.open(shareUrl, "_blank", "noopener,noreferrer");
};
const email = () => {
window.location.href = `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(currentUrl())}`;
};
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<button type="button" onClick={copyLink} className={buttonClass} aria-label="کپی لینک">
{copied ? (
<span className="text-[10px]">✓</span>
) : (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<rect x="8" y="8" width="10" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M15 8V6.5A1.5 1.5 0 0 0 13.5 5h-7A1.5 1.5 0 0 0 5 6.5v7A1.5 1.5 0 0 0 6.5 15H8" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
</button>
<button type="button" onClick={() => openShare("whatsapp")} className={buttonClass} aria-label="اشتراک در واتساپ">
<span className="text-[11px] font-bold">W</span>
</button>
<button type="button" onClick={() => openShare("linkedin")} className={buttonClass} aria-label="اشتراک در لینکدین">
<span className="text-[11px] font-bold">in</span>
</button>
<button type="button" onClick={email} className={buttonClass} aria-label="ارسال با ایمیل">
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<rect x="3.5" y="5.5" width="17" height="13" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="m4.5 7 7.5 6 7.5-6" stroke="currentColor" strokeWidth="1.5" />
</svg>
</button>
</div>
);
}
@@ -1,138 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Reveal } from "@/components/Reveal";
import { Pagination } from "@/components/shared/Pagination";
import type { DisplayProject, PortfolioCategory } from "@/lib/api";
const ALL_ID = "__all__";
const PAGE_SIZE = 9;
/**
* Category tabs above a grid — no search, click a tab to filter in place.
*
* Tabs only render when there is more than one real category on the fetched
* projects. The client's portfolio items currently share a single category,
* so the bar stays hidden rather than show one meaningless tab; the moment
* a second category exists in the CMS, this switches on with no code change.
*/
export function PortfolioBrowser({
projects,
categories,
}: {
projects: DisplayProject[];
categories: PortfolioCategory[];
}) {
const [activeId, setActiveId] = useState(ALL_ID);
const [page, setPage] = useState(1);
const showTabs = categories.length > 1;
const filtered = useMemo(
() => (activeId === ALL_ID ? projects : projects.filter((p) => p.categoryId === activeId)),
[projects, activeId],
);
// Switching category with a stale page number could land on an empty page.
useEffect(() => setPage(1), [activeId]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<div>
{showTabs && (
<div className="no-scrollbar mx-auto mb-14 max-w-[1400px] overflow-x-auto px-6 lg:px-12">
<ul className="flex w-max items-center gap-10 border-b border-line">
<li>
<button
type="button"
onClick={() => setActiveId(ALL_ID)}
aria-current={activeId === ALL_ID}
className={`relative whitespace-nowrap pb-4 text-sm font-bold transition-colors duration-300 ${
activeId === ALL_ID ? "text-ink" : "text-muted hover:text-ink"
}`}
>
همه پروژه‌ها
<span
aria-hidden="true"
className={`absolute inset-x-0 -bottom-px h-[2px] bg-gold transition-transform duration-300 ease-out-soft ${
activeId === ALL_ID ? "scale-x-100" : "scale-x-0"
}`}
/>
</button>
</li>
{categories.map((cat) => (
<li key={cat.id}>
<button
type="button"
onClick={() => setActiveId(cat.id)}
aria-current={activeId === cat.id}
className={`relative whitespace-nowrap pb-4 text-sm font-bold transition-colors duration-300 ${
activeId === cat.id ? "text-ink" : "text-muted hover:text-ink"
}`}
>
{cat.name}
<span
aria-hidden="true"
className={`absolute inset-x-0 -bottom-px h-[2px] bg-gold transition-transform duration-300 ease-out-soft ${
activeId === cat.id ? "scale-x-100" : "scale-x-0"
}`}
/>
</button>
</li>
))}
</ul>
</div>
)}
{filtered.length === 0 ? (
<p className="mx-auto max-w-[1400px] px-6 py-16 text-center text-muted lg:px-12">
پروژه‌ای در این دسته یافت نشد.
</p>
) : (
<ul className="mx-auto grid max-w-[1400px] gap-7 px-5 md:grid-cols-2 lg:gap-10 lg:px-10">
{pageItems.map((project, i) => (
<Reveal as="li" key={project.slug} delay={(i % 6) * 70}>
<Link href={`/portfolios/${project.slug}`} className="group block bg-white">
<div className="relative aspect-[4/3] overflow-hidden bg-ink">
<Image
src={project.image}
alt={`${project.type} ${project.name}`}
fill
quality={80}
sizes="(max-width: 768px) 100vw, 50vw"
loading={i < 3 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.06]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-6 lg:p-8">
<span className="latin block text-[0.66rem] tracking-[0.28em] text-gold-soft">
{project.nameLatin}
</span>
<h3 className="mt-2.5 text-[clamp(1.35rem,2vw,2rem)] font-bold text-white">{project.name}</h3>
<span className="mt-1.5 block text-sm text-white/65">{project.type}</span>
<span className="mt-5 flex w-max items-center gap-3 border-b border-white/80 pb-1 text-sm text-white transition-colors duration-300 group-hover:text-gold-soft">
مشاهده پروژه
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
</svg>
</span>
</div>
</div>
</Link>
</Reveal>
))}
</ul>
)}
<div className="mx-auto max-w-[1400px] px-6 pb-24 lg:px-12 lg:pb-32">
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { Reveal } from "@/components/Reveal";
import type { DisplayProject, ProjectCategory } from "@/lib/api";
const ALL_ID = "__all__";
/**
* Category tabs above the project list — no search, click a tab to filter in place.
*
* Tabs only render when there is more than one real category on the fetched
* projects. The client's project items currently share a single category,
* so the bar stays hidden rather than show one meaningless tab; the moment
* a second category exists in the CMS, this switches on with no code change.
*/
export function ProjectBrowser({
projects,
categories,
}: {
projects: DisplayProject[];
categories: ProjectCategory[];
}) {
const [activeId, setActiveId] = useState(ALL_ID);
const projectListRef = useRef<HTMLUListElement>(null);
const showTabs = categories.length > 1;
const filtered = useMemo(
() => (activeId === ALL_ID ? projects : projects.filter((p) => p.categoryId === activeId)),
[projects, activeId],
);
useEffect(() => {
projectListRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [activeId]);
return (
<div className="bg-ink">
{showTabs && (
<div className="no-scrollbar mx-auto max-w-[1400px] overflow-x-auto bg-paper px-6 pt-10 lg:px-12 lg:pt-14">
<ul className="flex w-max items-center gap-10 border-b border-line">
<li>
<button
type="button"
onClick={() => setActiveId(ALL_ID)}
aria-current={activeId === ALL_ID}
className={`relative whitespace-nowrap pb-4 text-sm font-bold transition-colors duration-300 ${
activeId === ALL_ID ? "text-ink" : "text-muted hover:text-ink"
}`}
>
همه پروژه‌ها
<span
aria-hidden="true"
className={`absolute inset-x-0 -bottom-px h-[2px] bg-gold transition-transform duration-300 ease-out-soft ${
activeId === ALL_ID ? "scale-x-100" : "scale-x-0"
}`}
/>
</button>
</li>
{categories.map((cat) => (
<li key={cat.id}>
<button
type="button"
onClick={() => setActiveId(cat.id)}
aria-current={activeId === cat.id}
className={`relative whitespace-nowrap pb-4 text-sm font-bold transition-colors duration-300 ${
activeId === cat.id ? "text-ink" : "text-muted hover:text-ink"
}`}
>
{cat.name}
<span
aria-hidden="true"
className={`absolute inset-x-0 -bottom-px h-[2px] bg-gold transition-transform duration-300 ease-out-soft ${
activeId === cat.id ? "scale-x-100" : "scale-x-0"
}`}
/>
</button>
</li>
))}
</ul>
</div>
)}
{filtered.length === 0 ? (
<p className="mx-auto max-w-[1400px] bg-paper px-6 py-16 text-center text-muted lg:px-12">
پروژه‌ای در این دسته یافت نشد.
</p>
) : (
<ul
ref={projectListRef}
className="no-scrollbar h-svh snap-y snap-mandatory overflow-y-auto scroll-smooth"
>
{filtered.map((project, i) => (
<Reveal as="li" key={project.slug} delay={(i % 3) * 70} className="snap-start snap-always">
<Link
href={`/projects/${project.slug}`}
className="group relative block h-svh min-h-[620px] overflow-hidden bg-ink"
>
<Image
src={project.image}
alt={project.name}
fill
quality={88}
sizes="100vw"
loading={i === 0 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1600ms] ease-out-soft group-hover:scale-[1.035]"
/>
<div className="absolute inset-0 bg-black/25 transition-colors duration-700 group-hover:bg-black/35" />
<div className="absolute inset-0 flex items-center justify-center px-6 text-center text-white">
<div className="transition-transform duration-700 ease-out-soft group-hover:-translate-y-2">
<h2 className="text-[clamp(28px,8vw,38px)] leading-[1.35] font-bold text-white drop-shadow-sm lg:text-[clamp(36px,calc(5.2vw-20px),64px)] lg:leading-[1.2]">
{project.name}
</h2>
{project.nameLatin && (
<span className="mt-4 block text-base tracking-[0.22em] text-white/70 uppercase">
{project.nameLatin}
</span>
)}
</div>
</div>
<div className="absolute inset-x-0 bottom-[8%] flex justify-center px-6">
<span className="flex min-w-56 items-center justify-center gap-3 border border-white/70 bg-black/20 px-8 py-3 text-sm text-white backdrop-blur-sm transition-[background-color,color,border-color] duration-500 group-hover:border-white group-hover:bg-white group-hover:text-ink">
مشاهده پروژه
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
</svg>
</span>
</div>
</Link>
</Reveal>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { Swiper as SwiperInstance } from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "./raoofi-atelier.css";
const media = (name: string) => `/raoofi-media/raoofi-home/${name}`;
const collection = [
{ image: media("collection-1.webp"), title: "اقامتگاهی فراتر از تعریف", subtitle: "فضایی برای یک زندگی یگانه" },
{ image: media("collection-2.webp"), title: "خانه‌ای در اوج آسمان", subtitle: "روایتی شخصی از آرامش" },
{ image: media("collection-3.webp"), title: "شاهکاری برای یک نفر", subtitle: "طراحی‌شده برای ماندن" },
];
export function RaoofiHome() {
const galleryRef = useRef<SwiperInstance | null>(null);
const [activeSlide, setActiveSlide] = useState(0);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => entries.forEach((entry) => entry.isIntersecting && entry.target.classList.add("is-visible")),
{ threshold: 0.12 },
);
document.querySelectorAll(".atelier-reveal").forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<main className="atelier-page" dir="rtl">
<section className="atelier-hero">
<video autoPlay muted loop playsInline preload="metadata" poster={media("hero-poster.jpg")}>
<source src={media("hero-mobile.mp4")} media="(max-width:700px)" type="video/mp4" />
<source src={media("hero-desktop.mp4")} type="video/mp4" />
</video>
<div className="atelier-shade" />
<div className="atelier-hero__brand">
<strong>رئوفی هوم</strong>
<span>هنرِ خلق یک زندگی منحصربه‌فرد</span>
</div>
<a className="atelier-hero__link" href="#collection">کشف مجموعه</a>
<span className="atelier-scroll" aria-hidden="true">⌄</span>
</section>
<section className="atelier-intro atelier-reveal">
<span>رئوفی هوم</span>
<h1>همکاری در بالاترین سطح</h1>
<div>
<p>رئوفی هوم برای کسانی شکل گرفته است که معیارهای زندگی خود را تعریف می‌کنند و می‌خواهند چشم‌انداز شخصی‌شان در تمام جزئیات فضا دیده شود.</p>
<p>این مسیر با همراهی معماران، طراحان و استادکاران برجسته، کمیاب‌ترین ایده‌ها را به آثاری دقیق و ماندگار تبدیل می‌کند.</p>
<p>ریشه در هنر، مهندسی و ساخت سفارشی دارد؛ تعریفی امروزی از کیفیت که عمیقاً شخصی و همواره یگانه است.</p>
</div>
</section>
<section className="atelier-wide-image atelier-reveal">
<Image src={media("story-1.webp")} alt="فضای معماری اختصاصی" fill sizes="100vw" />
</section>
<section className="atelier-intro atelier-intro--second atelier-reveal">
<span>خلق منحصربه‌فرد</span>
<h2>آثاری که تنها یک‌بار ساخته می‌شوند</h2>
<div>
<p>هر پروژه پاسخی اختصاصی به شیوه زندگی، خواسته‌ها و رویاهای صاحب آن است؛ اثری که از تعریف‌های معمول تجمل و شکوه فراتر می‌رود.</p>
<p>از معماری و طراحی داخلی تا انتخاب هنر، متریال و جزئیات نهایی، همه‌چیز برای شکل‌دادن به یک جهان کاملاً شخصی کنار هم قرار می‌گیرد.</p>
<p>نتیجه، مجموعه‌ای محدود از فضاهای کمیاب است که مشابهی ندارند و با گذر زمان ارزشمندتر می‌شوند.</p>
</div>
</section>
<section className="atelier-editorial">
<div className="atelier-editorial__pair atelier-reveal">
<div className="atelier-editorial__image"><Image src={media("story-2.webp")} alt="جزئیات معماری لوکس" fill sizes="50vw" /></div>
<div className="atelier-editorial__image"><Image src={media("story-3.jpg")} alt="خانه‌ای با طراحی سفارشی" fill sizes="50vw" /></div>
</div>
<div className="atelier-editorial__copy atelier-reveal">
<p>مسیر رئوفی هوم با یک چشم‌انداز روشن آغاز شد: جابه‌جایی معیارهای بازار از طریق پروژه‌هایی که در طراحی، کیفیت ساخت و تجربه زندگی پیشگام هستند.</p>
</div>
<div className="atelier-editorial__feature atelier-reveal">
<div className="atelier-editorial__image"><Image src={media("story-4.webp")} alt="فضای داخلی معاصر" fill sizes="62vw" /></div>
<div className="atelier-editorial__copy"><p>هر اثر تازه، استاندارد قبلی را پشت سر می‌گذارد. پیوستگی میان ایده، طراحی و اجرا باعث می‌شود هر خانه هویتی مستقل و کیفیتی تکرارنشدنی پیدا کند.</p></div>
</div>
</section>
<section id="collection" className="atelier-collection">
<header className="atelier-section-title atelier-reveal">
<span>مجموعه آتلیه</span>
<h2>فصلی تازه از یک زندگی استثنایی</h2>
</header>
<div className="atelier-gallery atelier-reveal">
<Swiper
dir="ltr"
loop
speed={850}
slidesPerView={1}
grabCursor
onSwiper={(swiper) => { galleryRef.current = swiper; setActiveSlide(swiper.realIndex); }}
onSlideChange={(swiper) => setActiveSlide(swiper.realIndex)}
>
{collection.map((item) => (
<SwiperSlide key={item.title}>
<article dir="rtl">
<Image src={item.image} alt={item.title} fill sizes="100vw" />
<div className="atelier-shade" />
<div className="atelier-gallery__caption"><small>{item.subtitle}</small><h3>{item.title}</h3></div>
</article>
</SwiperSlide>
))}
</Swiper>
<div className="atelier-gallery__progress" aria-hidden="true">
{collection.map((item, index) => <button key={item.title} type="button" className={activeSlide === index ? "is-active" : ""} onClick={() => galleryRef.current?.slideToLoop(index)} />)}
</div>
<div className="atelier-gallery__buttons">
<button type="button" onClick={() => galleryRef.current?.slidePrev()} aria-label="اثر قبلی">→</button>
<button type="button" onClick={() => galleryRef.current?.slideNext()} aria-label="اثر بعدی">←</button>
</div>
</div>
</section>
<section className="atelier-manifesto atelier-reveal">
<p>شوقِ جان‌بخشیدن به رویاها و چشم‌اندازهای کسانی که آینده را می‌سازند، تعهد استوار رئوفی هوم را به تصور امر خارق‌العاده و تبدیل آن به واقعیت شکل می‌دهد.</p>
<p>آثار منحصربه‌فرد و معیارساز رئوفی هوم، خانه‌ها و پناهگاه‌هایی برای اندیشمندان، رهبران و سازندگان فرداست؛ فضاهایی برای کسانی که جسارت متفاوت‌بودن دارند.</p>
</section>
</main>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { Swiper as SwiperInstance } from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "./raoofi-home.css";
const media = (name: string) => `/raoofi-media/raoofi-home/${name}`;
const collection = [
{ image: media("collection-1.webp"), title: "اقامتگاهی فراتر از تعریف", subtitle: "فضایی برای یک زندگی یگانه" },
{ image: media("collection-2.webp"), title: "خانه‌ای در اوج آسمان", subtitle: "روایتی شخصی از آرامش" },
{ image: media("collection-3.webp"), title: "شاهکاری برای یک نفر", subtitle: "طراحی‌شده برای ماندن" },
];
export function RaoofiHome() {
const galleryRef = useRef<SwiperInstance | null>(null);
const [activeSlide, setActiveSlide] = useState(0);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => entries.forEach((entry) => entry.isIntersecting && entry.target.classList.add("is-visible")),
{ threshold: 0.12 },
);
document.querySelectorAll(".atelier-reveal").forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<main className="atelier-page" dir="rtl">
<section className="atelier-hero">
<video autoPlay muted loop playsInline preload="metadata" poster={media("hero-poster.jpg")}>
<source src={media("hero-mobile.mp4")} media="(max-width:700px)" type="video/mp4" />
<source src={media("hero-desktop.mp4")} type="video/mp4" />
</video>
<div className="atelier-shade" />
<div className="atelier-hero__brand">
<strong>رئوفی هوم</strong>
<span>هنرِ خلق یک زندگی منحصربه‌فرد</span>
</div>
<a className="atelier-hero__link" href="#collection">کشف مجموعه</a>
<span className="atelier-scroll" aria-hidden="true">⌄</span>
</section>
<section className="atelier-intro atelier-reveal">
<span>رئوفی هوم</span>
<h1>همکاری در بالاترین سطح</h1>
<div>
<p>رئوفی هوم برای کسانی شکل گرفته است که معیارهای زندگی خود را تعریف می‌کنند و می‌خواهند چشم‌انداز شخصی‌شان در تمام جزئیات فضا دیده شود.</p>
<p>این مسیر با همراهی معماران، طراحان و استادکاران برجسته، کمیاب‌ترین ایده‌ها را به آثاری دقیق و ماندگار تبدیل می‌کند.</p>
<p>ریشه در هنر، مهندسی و ساخت سفارشی دارد؛ تعریفی امروزی از کیفیت که عمیقاً شخصی و همواره یگانه است.</p>
</div>
</section>
<section className="atelier-wide-image atelier-reveal">
<Image src={media("story-1.webp")} alt="فضای معماری اختصاصی" fill sizes="100vw" />
</section>
<section className="atelier-intro atelier-intro--second atelier-reveal">
<span>خلق منحصربه‌فرد</span>
<h2>آثاری که تنها یک‌بار ساخته می‌شوند</h2>
<div>
<p>هر پروژه پاسخی اختصاصی به شیوه زندگی، خواسته‌ها و رویاهای صاحب آن است؛ اثری که از تعریف‌های معمول تجمل و شکوه فراتر می‌رود.</p>
<p>از معماری و طراحی داخلی تا انتخاب هنر، متریال و جزئیات نهایی، همه‌چیز برای شکل‌دادن به یک جهان کاملاً شخصی کنار هم قرار می‌گیرد.</p>
<p>نتیجه، مجموعه‌ای محدود از فضاهای کمیاب است که مشابهی ندارند و با گذر زمان ارزشمندتر می‌شوند.</p>
</div>
</section>
<section className="atelier-editorial">
<div className="atelier-editorial__pair atelier-reveal">
<div className="atelier-editorial__image"><Image src={media("story-2.webp")} alt="جزئیات معماری لوکس" fill sizes="50vw" /></div>
<div className="atelier-editorial__image"><Image src={media("story-3.jpg")} alt="خانه‌ای با طراحی سفارشی" fill sizes="50vw" /></div>
</div>
<div className="atelier-editorial__copy atelier-reveal">
<p>مسیر رئوفی هوم با یک چشم‌انداز روشن آغاز شد: جابه‌جایی معیارهای بازار از طریق پروژه‌هایی که در طراحی، کیفیت ساخت و تجربه زندگی پیشگام هستند.</p>
</div>
<div className="atelier-editorial__feature atelier-reveal">
<div className="atelier-editorial__image"><Image src={media("story-4.webp")} alt="فضای داخلی معاصر" fill sizes="62vw" /></div>
<div className="atelier-editorial__copy"><p>هر اثر تازه، استاندارد قبلی را پشت سر می‌گذارد. پیوستگی میان ایده، طراحی و اجرا باعث می‌شود هر خانه هویتی مستقل و کیفیتی تکرارنشدنی پیدا کند.</p></div>
</div>
</section>
<section id="collection" className="atelier-collection">
<header className="atelier-section-title atelier-reveal">
<span>مجموعه آتلیه</span>
<h2>فصلی تازه از یک زندگی استثنایی</h2>
</header>
<div className="atelier-gallery atelier-reveal">
<Swiper
dir="ltr"
loop
speed={850}
slidesPerView={1}
grabCursor
onSwiper={(swiper) => { galleryRef.current = swiper; setActiveSlide(swiper.realIndex); }}
onSlideChange={(swiper) => setActiveSlide(swiper.realIndex)}
>
{collection.map((item) => (
<SwiperSlide key={item.title}>
<article dir="rtl">
<Image src={item.image} alt={item.title} fill sizes="100vw" />
<div className="atelier-shade" />
<div className="atelier-gallery__caption"><small>{item.subtitle}</small><h3>{item.title}</h3></div>
</article>
</SwiperSlide>
))}
</Swiper>
<div className="atelier-gallery__progress" aria-hidden="true">
{collection.map((item, index) => <button key={item.title} type="button" className={activeSlide === index ? "is-active" : ""} onClick={() => galleryRef.current?.slideToLoop(index)} />)}
</div>
<div className="atelier-gallery__buttons">
<button type="button" onClick={() => galleryRef.current?.slidePrev()} aria-label="اثر قبلی">→</button>
<button type="button" onClick={() => galleryRef.current?.slideNext()} aria-label="اثر بعدی">←</button>
</div>
</div>
</section>
<section className="atelier-manifesto atelier-reveal">
<p>شوقِ جان‌بخشیدن به رویاها و چشم‌اندازهای کسانی که آینده را می‌سازند، تعهد استوار رئوفی هوم را به تصور امر خارق‌العاده و تبدیل آن به واقعیت شکل می‌دهد.</p>
<p>آثار منحصربه‌فرد و معیارساز رئوفی هوم، خانه‌ها و پناهگاه‌هایی برای اندیشمندان، رهبران و سازندگان فرداست؛ فضاهایی برای کسانی که جسارت متفاوت‌بودن دارند.</p>
</section>
</main>
);
}
@@ -0,0 +1,10 @@
.atelier-page{overflow-x:clip;background:#f4f3f0;color:#10183b}.atelier-page h1,.atelier-page h2,.atelier-page h3,.atelier-page p{margin:0}.atelier-shade{position:absolute;inset:0;background:linear-gradient(0deg,rgba(5,8,15,.5),rgba(5,8,15,.04) 62%)}
.atelier-hero{position:relative;height:100svh;min-height:650px;overflow:hidden;background:#111}.atelier-hero video{width:100%;height:100%;object-fit:cover}.atelier-hero__brand{position:absolute;z-index:2;top:49%;right:0;left:0;display:flex;align-items:center;flex-direction:column;padding:30px 20px;background:rgba(7,10,15,.58);backdrop-filter:blur(16px);color:#fff;text-align:center}.atelier-hero__brand strong{font-size:clamp(34px,4vw,58px);font-weight:700;line-height:1.35}.atelier-hero__brand span{margin-top:12px;font-size:13px;letter-spacing:.08em}.atelier-hero__link{position:absolute;z-index:2;top:112px;left:50%;transform:translateX(-50%);border-bottom:1px solid rgba(255,255,255,.75);padding-bottom:6px;color:#fff;font-size:12px}.atelier-scroll{position:absolute;z-index:2;bottom:22px;left:50%;color:#fff;font-size:32px;animation:atelier-bob 1.8s ease-in-out infinite}
.atelier-intro{width:min(1040px,86%);margin:auto;padding:150px 0;text-align:center}.atelier-intro>span,.atelier-section-title>span{display:block;color:#9b8668;font-size:12px;letter-spacing:.08em}.atelier-intro h1,.atelier-intro h2,.atelier-section-title h2{margin:28px auto 45px;font-size:clamp(28px,3.2vw,46px);font-weight:700;line-height:1.45}.atelier-intro>div{display:grid;gap:34px;font-size:clamp(15px,1.25vw,19px);line-height:2}.atelier-intro--second{padding-top:165px}
.atelier-wide-image{position:relative;height:min(68vw,850px);overflow:hidden;background:#ddd}.atelier-wide-image img,.atelier-editorial img{object-fit:cover}
.atelier-editorial{padding:0 4vw 150px}.atelier-editorial__pair{display:grid;grid-template-columns:1fr 1fr;gap:3vw}.atelier-editorial__image{position:relative;height:46vw;max-height:720px;overflow:hidden}.atelier-editorial__copy{width:min(760px,86%);margin:90px auto;font-size:clamp(17px,1.7vw,24px);font-weight:500;line-height:2;text-align:center}.atelier-editorial__feature{display:grid;grid-template-columns:1.35fr .65fr;align-items:stretch;gap:0;background:#fff}.atelier-editorial__feature .atelier-editorial__image{height:48vw;max-height:760px}.atelier-editorial__feature .atelier-editorial__copy{display:flex;align-items:center;width:auto;margin:0;padding:8vw 5vw;font-size:clamp(16px,1.45vw,21px);text-align:right}
.atelier-collection{padding:145px 0 0;background:#fff}.atelier-section-title{width:min(980px,88%);margin:0 auto 90px;text-align:center}.atelier-gallery{position:relative;height:min(72vw,850px);overflow:hidden;background:#111}.atelier-gallery .swiper,.atelier-gallery .swiper-wrapper,.atelier-gallery .swiper-slide,.atelier-gallery article{height:100%}.atelier-gallery article{position:relative}.atelier-gallery article>img{object-fit:cover}.atelier-gallery__caption{position:absolute;z-index:2;right:7%;bottom:13%;color:#fff}.atelier-gallery__caption small{font-size:13px}.atelier-gallery__caption h3{max-width:700px;margin-top:12px;color:#fff;font-size:clamp(28px,3.4vw,46px);font-weight:700}.atelier-gallery__progress{position:absolute;z-index:4;right:7%;bottom:6%;left:22%;display:flex;direction:ltr}.atelier-gallery__progress button{height:2px;flex:1;border:0;background:rgba(255,255,255,.35);padding:0}.atelier-gallery__progress button.is-active{background:#fff}.atelier-gallery__buttons{position:absolute;z-index:5;bottom:3%;left:5%;display:flex;gap:12px}.atelier-gallery__buttons button{display:grid;width:50px;height:50px;place-items:center;border:1px solid rgba(255,255,255,.75);border-radius:50%;background:transparent;color:#fff;font-size:21px;cursor:pointer}.atelier-gallery__buttons button:hover{background:#fff;color:#10183b}
.atelier-manifesto{display:grid;gap:30px;width:min(820px,calc(100% - 40px));margin:auto;padding:105px 0 115px;color:#10183b;font-size:clamp(15px,1.15vw,18px);font-weight:500;line-height:1.9;text-align:center}
.atelier-reveal{opacity:0;transform:translateY(34px);transition:opacity .9s cubic-bezier(.22,1,.36,1),transform .9s cubic-bezier(.22,1,.36,1)}.atelier-reveal.is-visible{opacity:1;transform:none}@keyframes atelier-bob{50%{transform:translateY(8px)}}
@media(max-width:700px){.atelier-hero{min-height:620px}.atelier-hero__brand strong{font-size:34px}.atelier-hero__brand span{max-width:250px;font-size:11px}.atelier-hero__link{top:88px}.atelier-intro{width:calc(100% - 40px);padding:100px 0}.atelier-intro h1,.atelier-intro h2,.atelier-section-title h2{margin:24px auto 34px;font-size:clamp(28px,8vw,38px)}.atelier-intro>div{gap:26px;font-size:14px}.atelier-wide-image{height:120vw}.atelier-editorial{padding:0 18px 100px}.atelier-editorial__pair{grid-template-columns:1fr;gap:18px}.atelier-editorial__image{height:110vw}.atelier-editorial__pair>div:nth-child(2){height:80vw}.atelier-editorial__copy{margin:65px auto;font-size:17px}.atelier-editorial__feature{grid-template-columns:1fr}.atelier-editorial__feature .atelier-editorial__image{height:110vw}.atelier-editorial__feature .atelier-editorial__copy{padding:55px 25px}.atelier-collection{padding-top:100px}.atelier-section-title{margin-bottom:60px}.atelier-gallery{height:76svh;min-height:570px}.atelier-gallery__caption{right:24px;left:24px;bottom:18%}.atelier-gallery__caption h3{font-size:30px}.atelier-gallery__progress{right:22px;bottom:8%;left:140px}.atelier-gallery__buttons{bottom:5%;left:20px}.atelier-manifesto{gap:24px;padding:75px 0 85px;font-size:14px;line-height:2}}
@media(prefers-reduced-motion:reduce){.atelier-reveal{opacity:1;transform:none}.atelier-scroll{animation:none}}
@@ -0,0 +1,10 @@
.atelier-page{overflow-x:clip;background:#f4f3f0;color:#10183b}.atelier-page h1,.atelier-page h2,.atelier-page h3,.atelier-page p{margin:0}.atelier-shade{position:absolute;inset:0;background:linear-gradient(0deg,rgba(5,8,15,.5),rgba(5,8,15,.04) 62%)}
.atelier-hero{position:relative;height:100svh;min-height:650px;overflow:hidden;background:#111}.atelier-hero video{width:100%;height:100%;object-fit:cover}.atelier-hero__brand{position:absolute;z-index:2;top:49%;right:0;left:0;display:flex;align-items:center;flex-direction:column;padding:30px 20px;background:rgba(7,10,15,.58);backdrop-filter:blur(16px);color:#fff;text-align:center}.atelier-hero__brand strong{font-size:clamp(34px,4vw,58px);font-weight:700;line-height:1.35}.atelier-hero__brand span{margin-top:12px;font-size:13px;letter-spacing:.08em}.atelier-hero__link{position:absolute;z-index:2;top:112px;left:50%;transform:translateX(-50%);border-bottom:1px solid rgba(255,255,255,.75);padding-bottom:6px;color:#fff;font-size:12px}.atelier-scroll{position:absolute;z-index:2;bottom:22px;left:50%;color:#fff;font-size:32px;animation:atelier-bob 1.8s ease-in-out infinite}
.atelier-intro{width:min(1040px,86%);margin:auto;padding:150px 0;text-align:center}.atelier-intro>span,.atelier-section-title>span{display:block;color:#9b8668;font-size:12px;letter-spacing:.08em}.atelier-intro h1,.atelier-intro h2,.atelier-section-title h2{margin:28px auto 45px;font-size:clamp(28px,3.2vw,46px);font-weight:700;line-height:1.45}.atelier-intro>div{display:grid;gap:34px;font-size:clamp(15px,1.25vw,19px);line-height:2}.atelier-intro--second{padding-top:165px}
.atelier-wide-image{position:relative;height:min(68vw,850px);overflow:hidden;background:#ddd}.atelier-wide-image img,.atelier-editorial img{object-fit:cover}
.atelier-editorial{padding:0 4vw 150px}.atelier-editorial__pair{display:grid;grid-template-columns:1fr 1fr;gap:3vw}.atelier-editorial__image{position:relative;height:46vw;max-height:720px;overflow:hidden}.atelier-editorial__copy{width:min(760px,86%);margin:90px auto;font-size:clamp(17px,1.7vw,24px);font-weight:500;line-height:2;text-align:center}.atelier-editorial__feature{display:grid;grid-template-columns:1.35fr .65fr;align-items:stretch;gap:0;background:#fff}.atelier-editorial__feature .atelier-editorial__image{height:48vw;max-height:760px}.atelier-editorial__feature .atelier-editorial__copy{display:flex;align-items:center;width:auto;margin:0;padding:8vw 5vw;font-size:clamp(16px,1.45vw,21px);text-align:right}
.atelier-collection{padding:145px 0 0;background:#fff}.atelier-section-title{width:min(980px,88%);margin:0 auto 90px;text-align:center}.atelier-gallery{position:relative;height:min(72vw,850px);overflow:hidden;background:#111}.atelier-gallery .swiper,.atelier-gallery .swiper-wrapper,.atelier-gallery .swiper-slide,.atelier-gallery article{height:100%}.atelier-gallery article{position:relative}.atelier-gallery article>img{object-fit:cover}.atelier-gallery__caption{position:absolute;z-index:2;right:7%;bottom:13%;color:#fff}.atelier-gallery__caption small{font-size:13px}.atelier-gallery__caption h3{max-width:700px;margin-top:12px;color:#fff;font-size:clamp(28px,3.4vw,46px);font-weight:700}.atelier-gallery__progress{position:absolute;z-index:4;right:7%;bottom:6%;left:22%;display:flex;direction:ltr}.atelier-gallery__progress button{height:2px;flex:1;border:0;background:rgba(255,255,255,.35);padding:0}.atelier-gallery__progress button.is-active{background:#fff}.atelier-gallery__buttons{position:absolute;z-index:5;bottom:3%;left:5%;display:flex;gap:12px}.atelier-gallery__buttons button{display:grid;width:50px;height:50px;place-items:center;border:1px solid rgba(255,255,255,.75);border-radius:50%;background:transparent;color:#fff;font-size:21px;cursor:pointer}.atelier-gallery__buttons button:hover{background:#fff;color:#10183b}
.atelier-manifesto{display:grid;gap:30px;width:min(820px,calc(100% - 40px));margin:auto;padding:105px 0 115px;color:#10183b;font-size:clamp(15px,1.15vw,18px);font-weight:500;line-height:1.9;text-align:center}
.atelier-reveal{opacity:0;transform:translateY(34px);transition:opacity .9s cubic-bezier(.22,1,.36,1),transform .9s cubic-bezier(.22,1,.36,1)}.atelier-reveal.is-visible{opacity:1;transform:none}@keyframes atelier-bob{50%{transform:translateY(8px)}}
@media(max-width:700px){.atelier-hero{min-height:620px}.atelier-hero__brand strong{font-size:34px}.atelier-hero__brand span{max-width:250px;font-size:11px}.atelier-hero__link{top:88px}.atelier-intro{width:calc(100% - 40px);padding:100px 0}.atelier-intro h1,.atelier-intro h2,.atelier-section-title h2{margin:24px auto 34px;font-size:clamp(28px,8vw,38px)}.atelier-intro>div{gap:26px;font-size:14px}.atelier-wide-image{height:120vw}.atelier-editorial{padding:0 18px 100px}.atelier-editorial__pair{grid-template-columns:1fr;gap:18px}.atelier-editorial__image{height:110vw}.atelier-editorial__pair>div:nth-child(2){height:80vw}.atelier-editorial__copy{margin:65px auto;font-size:17px}.atelier-editorial__feature{grid-template-columns:1fr}.atelier-editorial__feature .atelier-editorial__image{height:110vw}.atelier-editorial__feature .atelier-editorial__copy{padding:55px 25px}.atelier-collection{padding-top:100px}.atelier-section-title{margin-bottom:60px}.atelier-gallery{height:76svh;min-height:570px}.atelier-gallery__caption{right:24px;left:24px;bottom:18%}.atelier-gallery__caption h3{font-size:30px}.atelier-gallery__progress{right:22px;bottom:8%;left:140px}.atelier-gallery__buttons{bottom:5%;left:20px}.atelier-manifesto{gap:24px;padding:75px 0 85px;font-size:14px;line-height:2}}
@media(prefers-reduced-motion:reduce){.atelier-reveal{opacity:1;transform:none}.atelier-scroll{animation:none}}
@@ -1,79 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import type { Swiper as SwiperInstance } from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import { serviceDetails, services } from "@/data/site";
import { EditorialCardCarousel } from "@/components/shared/EditorialCardCarousel";
import type { DisplayProject } from "@/lib/api";
import "./raoofi-services.css";
const base = "/raoofi-media/services";
const serviceImages = Array.from({ length: 6 }, (_, index) => `${base}/service-${(index % 5) + 1}.jpg`);
const fallbackProjects = [
["فضایی برای زندگی معاصر", "portfolio-1.webp"], ["جزئیاتی فراتر از انتظار", "portfolio-2.webp"],
["آرامش در قلب معماری", "portfolio-3.webp"], ["تعادل فرم و عملکرد", "portfolio-4.jpg"],
["روایتی تازه از کیفیت", "portfolio-5.jpg"], ["خانه‌ای با هویت مستقل", "portfolio-6.webp"],
["ساخته‌شده برای ماندن", "portfolio-7.webp"],
].map(([name, file], index) => ({ slug: "", name, type: `پروژه ${index + 1}`, image: `${base}/${file}` }));
export function RaoofiServices({ projects }: { projects: DisplayProject[] }) {
const projectSwiper = useRef<SwiperInstance | null>(null);
const [projectIndex, setProjectIndex] = useState(0);
const portfolio = projects.length
? projects.slice(0, 7).map((project) => ({ slug: project.slug, name: project.name, type: project.type, image: project.image }))
: fallbackProjects;
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => entries.forEach((entry) => entry.isIntersecting && entry.target.classList.add("is-visible")),
{ threshold: 0.12 },
);
document.querySelectorAll(".osa-reveal").forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<main className="osa-page" dir="rtl">
<section className="osa-hero">
<video autoPlay muted loop playsInline preload="metadata"><source src={`${base}/hero-desktop.mp4`} type="video/mp4" /></video>
<div className="osa-overlay" />
<div className="osa-hero-mark"><strong>RAOOFI</strong><span>Atelier</span></div>
<span className="osa-scroll">⌄</span>
</section>
<section className="osa-overview">
<div className="osa-heading osa-reveal"><span>نگاه کلی</span><h1>چشم‌انداز شما را<br />به واقعیت تبدیل می‌کنیم</h1><div><p>هنر و تجربهٔ گروه رئوفی را به طراحی، تأمین، مدیریت و اجرای کامل پروژه پیوند می‌دهیم تا هر فضا بازتابی دقیق از سبک زندگی و خواسته‌های شما باشد.</p><p>با همراهی معماران، هنرمندان و متخصصان برجسته، فضاهایی منحصربه‌فرد خلق می‌کنیم که زیبایی، عملکرد و کیفیت ماندگار را در کنار هم قرار می‌دهند.</p></div></div>
<div className="osa-collage osa-reveal"><div className="osa-collage-main"><Image src={`${base}/overview-1.webp`} alt="طراحی داخلی اختصاصی" fill sizes="65vw" /></div><div className="osa-collage-side"><Image src={`${base}/overview-2.jpg`} alt="فضای داخلی لوکس" fill sizes="35vw" /></div><div className="osa-sketch" aria-hidden="true">خطوط، نور، بافت<br />و جزئیات دقیق</div></div>
</section>
<section className="osa-services">
<div className="osa-heading osa-reveal"><span>خدمات</span><h2>از ایده تا تکمیل</h2><div><p>هر پروژه با دیدگاهی منحصربه‌فرد آغاز می‌شود. از نخستین جرقهٔ ایده تا اجرای نهایی، در تمام مسیر کنار شما هستیم.</p><p>می‌توانید مسیر کامل طراحی و اجرا یا هر یک از خدمات تخصصی را متناسب با نیاز پروژه انتخاب کنید.</p></div></div>
<div className="osa-reveal">
<EditorialCardCarousel
items={services.map((service, index) => ({ image: serviceImages[index], title: service.name, text: serviceDetails[service.slug].blurb }))}
previousLabel="خدمت قبلی"
nextLabel="خدمت بعدی"
/>
</div>
</section>
<section className="osa-portfolio">
<div className="osa-heading osa-reveal"><span>نمونه پروژه‌ها</span><h2>از طرح تا واقعیت</h2><div><p>پروژه‌های ما گسترهٔ خدمات، دقت اجرا و کیفیتی را نشان می‌دهند که از یک مسیر یکپارچهٔ طراحی و مدیریت شکل گرفته است.</p></div></div>
<div className="osa-project-nav osa-reveal">{portfolio.map((project, index) => <button key={`${project.name}-${index}`} className={projectIndex === index ? "is-active" : ""} onClick={() => projectSwiper.current?.slideToLoop(index)}>{project.name}</button>)}</div>
<div className="osa-project-stage osa-reveal">
<Swiper dir="ltr" loop={portfolio.length > 1} speed={850} slidesPerView={1} grabCursor onSwiper={(swiper) => { projectSwiper.current = swiper; setProjectIndex(swiper.realIndex); }} onSlideChange={(swiper) => setProjectIndex(swiper.realIndex)}>
{portfolio.map((project, index) => <SwiperSlide key={`${project.name}-image-${index}`}><article dir="rtl"><Image src={project.image} alt={project.name} fill sizes="100vw" unoptimized={project.image.startsWith("http")} /><div className="osa-overlay" /><div className="osa-project-caption"><small>{project.type}</small><h3>{project.name}</h3>{project.slug && <Link href={`/portfolios/${project.slug}`}>مشاهده پروژه</Link>}</div></article></SwiperSlide>)}
</Swiper>
<div className="osa-arrows osa-project-arrows"><button type="button" onClick={() => projectSwiper.current?.slidePrev()} aria-label="پروژه قبلی">←</button><button type="button" onClick={() => projectSwiper.current?.slideNext()} aria-label="پروژه بعدی">→</button></div>
</div>
</section>
<section className="osa-cta"><video autoPlay muted loop playsInline preload="metadata"><source src="/raoofi-media/about/cta-mobile.mp4" media="(max-width:700px)" type="video/mp4" /><source src="/raoofi-media/about/cta-desktop.mp4" type="video/mp4" /></video><div className="osa-overlay" /><div className="osa-cta-copy osa-reveal"><h2>فضایی منحصربه‌فرد کشف کنید</h2><p>برای آغاز پروژه و دریافت مشاوره با ما گفت‌وگو کنید.</p><Link href="/contactus">درخواست مشاوره</Link></div></section>
</main>
);
}
@@ -1,9 +0,0 @@
.osa-page{overflow-x:clip;background:#f5f4f1;color:#10183b}.osa-page h1,.osa-page h2,.osa-page h3,.osa-page p{margin:0}.osa-overlay{position:absolute;inset:0;background:linear-gradient(0deg,rgba(7,10,16,.48),rgba(7,10,16,.06) 62%)}
.osa-hero{position:relative;height:100vh;height:100svh;min-height:650px;overflow:hidden;background:#111}.osa-hero video{width:100%;height:100%;object-fit:cover}.osa-hero-mark{position:absolute;z-index:2;top:48%;left:50%;display:flex;width:100%;transform:translate(-50%,-50%);flex-direction:column;align-items:center;padding:30px 0;background:rgba(7,10,15,.55);backdrop-filter:blur(16px);color:#fff}.osa-hero-mark strong{font-family:Arial,sans-serif;font-size:clamp(34px,4vw,58px);font-weight:700;letter-spacing:.22em}.osa-hero-mark span{margin-top:-4px;font-family:Georgia,serif;font-size:clamp(22px,2.2vw,34px);font-weight:700;font-style:italic}.osa-scroll{position:absolute;z-index:2;bottom:28px;left:50%;color:#fff;font-size:32px;animation:osa-bob 1.8s ease-in-out infinite}
.osa-overview,.osa-services,.osa-portfolio{padding-top:150px}.osa-heading{width:min(1120px,86%);margin:0 auto 130px;text-align:center}.osa-heading>span{display:block;color:#9b8668;font-size:12px;letter-spacing:.08em}.osa-heading h1,.osa-heading h2{margin:30px auto 42px;font-size:clamp(28px,3.2vw,46px);font-weight:700;line-height:1.4}.osa-heading>div{display:grid;gap:34px;max-width:960px;margin:auto;font-size:clamp(15px,1.35vw,20px);line-height:2}.osa-collage{display:grid;grid-template-columns:1.05fr .95fr;grid-template-rows:450px 260px;gap:14px;padding:0 2vw 150px}.osa-collage-main,.osa-collage-side{position:relative;overflow:hidden}.osa-collage-main{grid-row:1/3}.osa-collage-side{grid-column:2}.osa-collage img{object-fit:cover;transition:transform 1s}.osa-collage:hover img{transform:scale(1.025)}.osa-sketch{display:flex;align-items:center;justify-content:center;background:#fff;color:#9b8668;font-size:clamp(20px,2vw,30px);font-weight:700;line-height:1.7;text-align:center}
.osa-services{padding-bottom:145px;background:#f0efec}.osa-arrows{position:absolute;z-index:5;right:5vw;bottom:18px;display:flex;direction:ltr;gap:18px}.osa-arrows button{border:0;background:transparent;color:#10183b;font-size:35px;cursor:pointer;transition:color .25s}.osa-arrows button:hover{color:#9b8668}
.osa-portfolio{padding-bottom:150px;background:#fff}.osa-project-nav{display:flex;gap:42px;width:92%;margin:0 auto 55px;padding-bottom:16px;overflow-x:auto;scrollbar-width:none;direction:rtl}.osa-project-nav::-webkit-scrollbar{display:none}.osa-project-nav button{flex:none;border:0;border-bottom:1px solid transparent;background:transparent;padding:0 0 12px;color:#8b8d94;font-size:17px;cursor:pointer}.osa-project-nav button.is-active{border-color:#10183b;color:#10183b}.osa-project-stage{position:relative;width:94%;height:min(68vw,780px);margin:auto;overflow:hidden;background:#111}.osa-project-stage,.osa-project-stage .swiper,.osa-project-stage .swiper-wrapper,.osa-project-stage .swiper-slide,.osa-project-stage article{height:min(68vw,780px)}.osa-project-stage article{position:relative}.osa-project-stage img{object-fit:cover}.osa-project-caption{position:absolute;z-index:2;right:6%;bottom:10%;color:#fff}.osa-project-caption h3{max-width:720px;margin:10px 0 20px;color:#fff;font-size:clamp(28px,3.4vw,46px);font-weight:700}.osa-project-caption a{border-bottom:1px solid #fff;padding-bottom:7px;color:#fff}.osa-project-arrows{right:auto;bottom:6%;left:4%;}.osa-project-arrows button{display:grid;width:52px;height:52px;place-items:center;border:1px solid rgba(255,255,255,.75);border-radius:50%;color:#fff;font-size:22px}.osa-project-arrows button:hover{background:#fff;color:#10183b}
.osa-cta{position:relative;height:650px;margin:0 5vw 80px;overflow:hidden;background:#111}.osa-cta video{width:100%;height:100%;object-fit:cover}.osa-cta-copy{position:absolute;z-index:2;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:25px;color:#fff;text-align:center}.osa-cta-copy h2{color:#fff;font-size:clamp(30px,3.2vw,46px);font-weight:700}.osa-cta-copy p{margin:12px 0 35px}.osa-cta-copy a{min-width:220px;border:1px solid #fff;padding:13px 25px;color:#fff}.osa-reveal{opacity:0;transform:translateY(34px);transition:opacity .9s cubic-bezier(.22,1,.36,1),transform .9s cubic-bezier(.22,1,.36,1)}.osa-reveal.is-visible{opacity:1;transform:none}
@keyframes osa-bob{50%{transform:translateY(8px)}}
@media(max-width:700px){.osa-hero{min-height:620px}.osa-hero-mark strong{font-size:34px}.osa-overview,.osa-services,.osa-portfolio{padding-top:95px}.osa-heading{width:calc(100% - 40px);margin-bottom:75px}.osa-heading h1,.osa-heading h2{margin:25px auto 35px;font-size:clamp(28px,8vw,38px)}.osa-heading>div{gap:25px;font-size:14px}.osa-collage{grid-template-columns:1fr;grid-template-rows:115vw 72vw 45vw;padding:0 18px 95px}.osa-collage-main{grid-row:auto}.osa-collage-side{grid-column:auto}.osa-arrows{right:18px;bottom:15px}.osa-project-nav{gap:26px;margin-bottom:35px}.osa-project-nav button{font-size:14px}.osa-project-stage,.osa-project-stage .swiper,.osa-project-stage .swiper-wrapper,.osa-project-stage .swiper-slide,.osa-project-stage article{height:72svh;min-height:570px}.osa-project-stage{width:100%}.osa-project-caption{right:24px;left:24px;bottom:18%}.osa-project-caption h3{font-size:30px}.osa-project-arrows{bottom:5%;left:22px}.osa-cta{height:560px;margin:0}.osa-cta-copy h2{font-size:34px}}
@media(prefers-reduced-motion:reduce){.osa-reveal{opacity:1;transform:none}.osa-scroll{animation:none}}
+2 -2
View File
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { DisplayProject } from "@/lib/api";
/**
* Wide 5:4 plates on a snapping rail, echoing the reference page's portfolio
* Wide 5:4 plates on a snapping rail, echoing the reference page's project
* carousel. Dots below, arrows to the side — both RTL-aware.
*/
export function ProjectGallery({ projects }: { projects: DisplayProject[] }) {
@@ -61,7 +61,7 @@ export function ProjectGallery({ projects }: { projects: DisplayProject[] }) {
key={project.slug}
className="w-[86vw] shrink-0 snap-center sm:w-[62vw] lg:w-[46vw] xl:w-[40rem]"
>
<Link href={`/portfolios/${project.slug}`} className="group block">
<Link href={`/projects/${project.slug}`} className="group block">
<figure>
<div className="relative aspect-[5/4] overflow-hidden bg-ink">
<Image
+81
View File
@@ -0,0 +1,81 @@
import type { DisplayNewsItem } from "@/lib/api";
export type DemoNewsKind = "news" | "blog";
export type DemoNewsItem = DisplayNewsItem & { kind: DemoNewsKind };
const sharedBody = [
"گروه رئوفی با تکیه بر تجربه، نگاه دقیق به جزئیات و همراهی تیم‌های تخصصی، مسیر تازه‌ای را در توسعه پروژه‌های شهری دنبال می‌کند.",
"در این مسیر، کیفیت اجرا و توجه به شیوه زندگی ساکنان از نخستین ایده تا تحویل نهایی در مرکز تصمیم‌ها قرار دارد. هر پروژه فرصتی است برای ساخت فضایی ماندگار که با شهر و نیازهای امروز پیوندی روشن داشته باشد.",
"این رویکرد، نتیجه سال‌ها تجربه در طراحی، سرمایه‌گذاری و اجرای پروژه‌هایی است که کیفیت معماری و آسایش زندگی را در کنار یکدیگر معنا می‌کنند.",
];
function body(title: string) {
return `<p>${title}</p>${sharedBody.map((paragraph) => `<p>${paragraph}</p>`).join("")}`;
}
/** Preview content shown only while the CMS has no published posts. */
export const demoNewsItems: DemoNewsItem[] = [
{
slug: "elysium-new-chapter", kind: "news", title: "فصل تازه‌ای از زندگی در الیزیوم آغاز شد",
date: "۱۷ شهریور ۱۴۰۵", publishedAt: "2026-09-08T09:00:00.000Z", image: "/raoofi-media/news/elysium-opening.jpg",
gallery: ["/raoofi-media/news/hayat-foundation.jpg", "/raoofi-media/news/ghasr.jpg", "/raoofi-media/news/golden-ghasr.jpg"],
bodyHtml: body("آغاز فصل تازه‌ای در الیزیوم، روایت شکل‌گیری فضایی برای یک زندگی آرام و معاصر است."),
},
{
slug: "architecture-and-the-city", kind: "blog", title: "معماری چگونه کیفیت زندگی شهری را تغییر می‌دهد؟",
date: "۲۹ مرداد ۱۴۰۵", publishedAt: "2026-08-20T09:00:00.000Z", image: "/raoofi-media/news/ghasr.jpg",
gallery: ["/raoofi-media/news/ghab.jpg", "/raoofi-media/news/aghaghiya.jpg", "/raoofi-media/news/ghasr-5.jpg"],
bodyHtml: body("معماری زمانی معنا پیدا می‌کند که فراتر از فرم، بر تجربه روزمره مردم اثر بگذارد."),
},
{
slug: "hayat-project-progress", kind: "news", title: "پروژه حیات وارد مرحله تازه‌ای از اجرا شد",
date: "۱۲ مرداد ۱۴۰۵", publishedAt: "2026-08-03T09:00:00.000Z", image: "/raoofi-media/news/hayat-foundation.jpg",
gallery: ["/raoofi-media/news/year-end-ceremony.jpg", "/raoofi-media/news/ghasr-8.jpg", "/raoofi-media/news/pania.jpg"],
bodyHtml: body("پروژه حیات با عبور از یکی از مهم‌ترین مراحل اجرایی، وارد فصل تازه‌ای از ساخت شد."),
},
{
slug: "crafting-lasting-homes", kind: "blog", title: "خانه‌های ماندگار چگونه ساخته می‌شوند؟",
date: "۲۴ تیر ۱۴۰۵", publishedAt: "2026-07-15T09:00:00.000Z", image: "/raoofi-media/news/ghab.jpg",
gallery: ["/raoofi-media/news/aghaghiya.jpg", "/raoofi-media/news/ghasr-5.jpg", "/raoofi-media/news/azadi.jpg"],
bodyHtml: body("خانه ماندگار حاصل تصمیم‌های دقیق، مصالح درست و توجه پیوسته به جزئیات است."),
},
{
slug: "raoofi-design-award", kind: "news", title: "تقدیر از رویکرد طراحی گروه رئوفی در رویداد معماری سال",
date: "۸ تیر ۱۴۰۵", publishedAt: "2026-06-29T09:00:00.000Z", image: "/raoofi-media/news/year-end-ceremony.jpg",
gallery: ["/raoofi-media/news/ghasr.jpg", "/raoofi-media/news/golden-ghasr.jpg", "/raoofi-media/news/ghasr-8.jpg"],
bodyHtml: body("رویکرد گروه رئوفی در ترکیب کیفیت طراحی و دقت اجرایی در رویداد معماری سال مورد تقدیر قرار گرفت."),
},
{
slug: "future-of-residential-design", kind: "blog", title: "آینده طراحی مسکونی؛ از فضای خصوصی تا تجربه جمعی",
date: "۲۱ خرداد ۱۴۰۵", publishedAt: "2026-06-11T09:00:00.000Z", image: "/raoofi-media/news/pania.jpg",
gallery: ["/raoofi-media/news/azadi.jpg", "/raoofi-media/news/ghab.jpg", "/raoofi-media/news/ghasr-5.jpg"],
bodyHtml: body("طراحی مسکونی امروز باید میان خلوت خانه، ارتباطات اجتماعی و کیفیت فضاهای مشترک تعادل ایجاد کند."),
},
{
slug: "ghasr-construction-update", kind: "news", title: "گزارش تازه از روند پیشرفت پروژه‌های قصر",
date: "۵ خرداد ۱۴۰۵", publishedAt: "2026-05-26T09:00:00.000Z", image: "/raoofi-media/news/golden-ghasr.jpg",
gallery: ["/raoofi-media/news/ghasr-8.jpg", "/raoofi-media/news/ghasr-5.jpg", "/raoofi-media/news/ghasr.jpg"],
bodyHtml: body("عملیات اجرایی پروژه‌های قصر مطابق برنامه زمان‌بندی در چند جبهه کاری ادامه دارد."),
},
{
slug: "annual-raoofi-gathering", kind: "news", title: "روایت گردهمایی سالانه خانواده بزرگ رئوفی",
date: "۱۸ اردیبهشت ۱۴۰۵", publishedAt: "2026-05-08T09:00:00.000Z", image: "/raoofi-media/news/aghaghiya.jpg",
gallery: ["/raoofi-media/news/year-end-ceremony.jpg", "/raoofi-media/news/pania.jpg", "/raoofi-media/news/hayat-foundation.jpg"],
bodyHtml: body("گردهمایی سالانه گروه رئوفی فرصتی برای مرور مسیر گذشته و هم‌فکری درباره چشم‌انداز پیش رو بود."),
},
{
slug: "new-residential-standard", kind: "news", title: "معیارهای تازه رئوفی برای توسعه پروژه‌های مسکونی",
date: "۲۹ فروردین ۱۴۰۵", publishedAt: "2026-04-18T09:00:00.000Z", image: "/raoofi-media/news/azadi.jpg",
gallery: ["/raoofi-media/news/golden-ghasr.jpg", "/raoofi-media/news/ghab.jpg", "/raoofi-media/news/ghasr.jpg"],
bodyHtml: body("معیارهای تازه گروه رئوفی بر کیفیت فضا، ماندگاری مصالح و تجربه یکپارچه ساکنان تمرکز دارد."),
},
];
export function getDemoNewsItems(kind?: DemoNewsKind) {
return kind ? demoNewsItems.filter((item) => item.kind === kind) : demoNewsItems;
}
export function getDemoNewsItem(slug: string, kind?: DemoNewsKind) {
return demoNewsItems.find((item) => item.slug === slug && (!kind || item.kind === kind)) ?? null;
}
File diff suppressed because one or more lines are too long
+16 -16
View File
@@ -25,9 +25,9 @@ export const site = {
export const nav = [
{ label: "صفحه اصلی", href: "/" },
{ label: "خدمات ما", href: "/our-services" },
{ label: "آتلیه رئوفی", href: "/atelier" },
{ label: "پروژه ها", href: "/portfolios" },
{ label: "رئوفی هوم", href: "/raoofi-home" },
{ label: "پروژه‌ها", href: "/projects" },
{ label: "مقالات و اخبار", href: "/news-and-blog" },
{ label: "درباره ما", href: "/aboutus" },
{ label: "تماس با ما", href: "/contactus" },
@@ -196,7 +196,7 @@ export const aboutPage = {
* Services page
*
* The live page (raoofigroup.com/our-services) carries only the six service
* names and the "با خدمات ما بیشتر آشنا شوید..." headline — there are no
* names and the original services headline — there are no
* descriptions and every card links back to "/". The `blurb` lines below were
* written for this rebuild: each one describes what the discipline itself
* covers, so nothing is claimed about the company that its own site does not
@@ -206,18 +206,18 @@ export const aboutPage = {
export const servicesPage = {
hero: {
caption: "OUR SERVICES",
title: "خدمات ما",
title: "آتلیه رئوفی",
breadcrumb: [
{ label: "صفحه اصلی", href: "/" },
{ label: "خدمات ما", href: "/our-services" },
{ label: "آتلیه رئوفی", href: "/atelier" },
],
},
overview: {
caption: "خدمات ما",
caption: "آتلیه رئوفی",
captionLatin: "SERVICES",
/** Headline as written on the live page. */
title: "با خدمات ما بیشتر آشنا شوید...",
title: "با آتلیه رئوفی بیشتر آشنا شوید...",
body: [
"رویکرد گروه رئوفی یکپارچه است؛ از ایده تا اجرا. شش حوزه‌ی تخصصی زیر، مراحل کامل یک پروژه را پوشش می‌دهند تا هر پروژه از نخستین طرح تا تحویل نهایی، درون یک ساختار واحد پیش برود.",
],
@@ -286,26 +286,26 @@ export const serviceDetails: Record<
};
/* ---------------------------------------------------------------------------
* Portfolio listing page — the projects themselves are fetched live from the
* Projects listing page — the projects themselves are fetched live from the
* meshkee API (see src/lib/api.ts); this is just the surrounding page copy.
* ------------------------------------------------------------------------- */
export const portfoliosPage = {
export const projectsPage = {
hero: {
caption: "OUR PORTFOLIO",
title: "نمونه کارها",
caption: "OUR PROJECTS",
title: "پروژه‌ها",
breadcrumb: [
{ label: "صفحه اصلی", href: "/" },
{ label: "نمونه کارها", href: "/portfolios" },
{ label: "پروژه‌ها", href: "/projects" },
],
},
overview: {
caption: "نمونه کارها",
captionLatin: "PORTFOLIO",
caption: "پروژه‌ها",
captionLatin: "PROJECTS",
title: "پروژه‌هایی که مسیر رئوفی را روایت می‌کنند",
body: [
"هر پروژه، فصلی از مسیر گروه رئوفی است؛ از نخستین ساخت‌وسازها تا برج‌های امروز. در ادامه، نمونه‌کارهای منتشرشده را می‌بینید.",
"هر پروژه، فصلی از مسیر گروه رئوفی است؛ از نخستین ساخت‌وسازها تا برج‌های امروز. در ادامه، پروژه‌های منتشرشده را می‌بینید.",
],
},
@@ -317,7 +317,7 @@ export const portfoliosPage = {
} as const;
/**
* Per-project video, keyed by portfolio slug. The meshkee API has no video
* Per-project video, keyed by project slug. The meshkee API has no video
* field, so this is the one manual step: paste an Aparat, YouTube, or direct
* video-file link here and that project's video section turns on by itself.
*/
+3 -3
View File
@@ -8,7 +8,7 @@ import { Services } from "@/components/Services";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { contact, site } from "@/data/site";
import { getRecentNews, getRecentPortfolios, toDisplayNewsItem, toDisplayProject } from "@/lib/api";
import { getRecentNews, getRecentProjects, toDisplayNewsItem, toDisplayProject } from "@/lib/api";
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
@@ -31,8 +31,8 @@ const organizationJsonLd = {
};
export default async function HomePage() {
const [portfolios, newsPosts] = await Promise.all([getRecentPortfolios(12), getRecentNews(3)]);
const displayProjects = portfolios.map(toDisplayProject);
const [projects, newsPosts] = await Promise.all([getRecentProjects(12), getRecentNews(3)]);
const displayProjects = projects.map(toDisplayProject);
const displayNews = newsPosts.map(toDisplayNewsItem);
return (
+135 -42
View File
@@ -5,15 +5,17 @@
* lives under `/tenants/{domain}/...` with no auth required.
*
* Note on categories: the tenant-level `/categories` endpoint exists but is
* unrelated to portfolios in practice (it returns an empty product-category
* list even though every portfolio item already carries its own categoryId/
* unrelated to the project records in practice (it returns an empty product-category
* list even though every project item already carries its own categoryId/
* categoryName). Category tabs are therefore derived directly from the
* portfolio items themselves — the moment the client assigns real categories
* project items themselves — the moment the client assigns real categories
* to projects in their CMS, the filter picks them up with no code change.
*/
import { cache } from "react";
import projectsFallback from "@/data/projects-fallback.json";
const API_BASE = "https://api.meshkee.com/api/v1";
const SITE_DOMAIN = "raoofigroup.com";
@@ -34,9 +36,33 @@ const LIST_PAGE_SIZE = 24;
* failure mode, not a hypothetical one. Failing fast here at least turns that
* into a clean error instead of a multi-minute hang.
*/
const REQUEST_TIMEOUT_MS = 10_000;
const REQUEST_TIMEOUT_MS = 5_000;
export type ApiPortfolioItem = {
/**
* Stable public aliases for existing CMS records. The API still supplies all
* project content; these aliases only let readable URLs resolve while the CMS
* list endpoint is temporarily unavailable.
*/
const PROJECT_API_SLUG_BY_ROUTE: Record<string, string> = {
elysium: "item",
ghab: "item-old-589",
"ghasr-9": "9",
ghasr: "item-old-591",
"ghasr-3": "3",
"ghasr-4": "4",
"ghasr-5": "5",
"ghasr-6": "6",
"ghasr-8": "8",
"ghasr-12": "item-old-605",
hayat: "item-old-611",
aghaghiya: "85",
pania: "item-old-631",
azadi: "item-old-632",
"golden-ghasr": "item-old-633",
onyx: "item-old-673",
};
export type ApiProjectItem = {
id: string;
businessId: string;
title: string;
@@ -59,30 +85,48 @@ export type ApiPortfolioItem = {
updatedAt: string;
};
type PortfoliosResponse = {
items: ApiPortfolioItem[];
type ProjectsResponse = {
items: ApiProjectItem[];
total: number;
page: number;
pageSize: number;
};
const PROJECTS_FALLBACK = projectsFallback as ProjectsResponse;
type FetchOptions = {
revalidate?: number;
tags?: string[];
};
async function apiFetch(path: string, options?: FetchOptions): Promise<Response> {
const url = `${API_BASE}${path}`;
const controller = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await fetch(`${API_BASE}${path}`, {
headers: { Accept: "application/json" },
next: {
revalidate: options?.revalidate ?? REVALIDATE_LIST_SECONDS,
tags: options?.tags,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
controller.abort();
reject(new Error(`meshkee API ${path} exceeded ${REQUEST_TIMEOUT_MS}ms`));
}, REQUEST_TIMEOUT_MS);
});
return await Promise.race([
fetch(url, {
headers: { Accept: "application/json" },
next: {
revalidate: options?.revalidate ?? REVALIDATE_LIST_SECONDS,
tags: options?.tags,
},
signal: controller.signal,
}),
timeout,
]);
} catch (cause) {
throw new Error(`meshkee API ${path} timed out or was unreachable`, { cause });
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
@@ -124,7 +168,7 @@ async function withApiFallback<T>(load: () => Promise<T>, fallback: T): Promise<
}
/** 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 ApiProjectItem | ApiBlogItem>(item: T): T {
return {
...item,
mainTextHtml: "",
@@ -150,31 +194,35 @@ async function fetchAllPages<T extends { items: unknown[]; total: number; pageSi
return [first, ...rest].flatMap((page) => page.items);
}
/** All published portfolio items, every page merged, in the CMS's own order. */
export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
/** All published projects, every page merged, in the CMS's own order. */
export const getProjects = cache(async (): Promise<ApiProjectItem[]> => {
const items = await withApiFallback(
() => fetchAllPages<PortfoliosResponse>(
() => fetchAllPages<ProjectsResponse>(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
{ tags: ["portfolios"] },
),
[],
PROJECTS_FALLBACK.items,
);
return (items as ApiPortfolioItem[])
return (items as ApiProjectItem[])
.filter((item) => item.status === "published")
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(stripListHeavyFields);
});
/** Recent published portfolios for home / about / services carousels. */
export const getRecentPortfolios = cache(async (limit = 12): Promise<ApiPortfolioItem[]> => {
/** Recent published projects for home, about, and atelier carousels. */
export const getRecentProjects = cache(async (limit = 12): Promise<ApiProjectItem[]> => {
const pageSize = Math.min(Math.max(limit, 1), 50);
const first = await withApiFallback(
() => apiGet<PortfoliosResponse>(
() => apiGet<ProjectsResponse>(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`,
{ tags: ["portfolios"] },
),
{ items: [], total: 0, page: 1, pageSize },
{
...PROJECTS_FALLBACK,
items: PROJECTS_FALLBACK.items.slice(0, pageSize),
pageSize,
},
);
return first.items
@@ -185,31 +233,32 @@ 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[]> => {
export const getProjectSlugs = cache(async (): Promise<string[]> => {
const items = await withApiFallback(
() => fetchAllPages<PortfoliosResponse>(
() => fetchAllPages<ProjectsResponse>(
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
{ tags: ["portfolios"] },
),
[],
PROJECTS_FALLBACK.items,
);
return (items as ApiPortfolioItem[])
return (items as ApiProjectItem[])
.filter((item) => item.status === "published")
.map((item) => item.slug);
.map(toProjectRouteSlug);
});
/** Single portfolio by slug — avoids loading the full list on detail pages. */
export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfolioItem | null> => {
/** Single project by slug — avoids loading the full list on detail pages. */
export const getProjectBySlug = cache(async (slug: string): Promise<ApiProjectItem | null> => {
const fallbackItem = PROJECTS_FALLBACK.items.find((project) => project.slug === slug) ?? null;
const data = await withApiFallback(
() => apiGetOptional<{ portfolio: ApiPortfolioItem }>(
() => apiGetOptional<{ portfolio: ApiProjectItem }>(
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
{
revalidate: REVALIDATE_DETAIL_SECONDS,
tags: ["portfolios", `portfolio:${slug}`],
},
),
null,
fallbackItem ? { portfolio: fallbackItem } : null,
);
const item = data?.portfolio;
@@ -220,11 +269,39 @@ export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfol
return item;
});
export type PortfolioCategory = { id: string; name: string; count: number };
/**
* Resolve the public, English project URL back to the slug expected by the
* Meshkee API. Legacy CMS slugs are accepted too so old links can redirect.
*/
export const getProjectByRouteSlug = cache(async (routeSlug: string): Promise<ApiProjectItem | null> => {
const knownApiSlug = PROJECT_API_SLUG_BY_ROUTE[routeSlug];
if (knownApiSlug) {
return getProjectBySlug(knownApiSlug);
}
/** Distinct categories actually present on published portfolio items. */
export function deriveCategories(items: ApiPortfolioItem[]): PortfolioCategory[] {
const byId = new Map<string, PortfolioCategory>();
if (Object.values(PROJECT_API_SLUG_BY_ROUTE).includes(routeSlug)) {
return getProjectBySlug(routeSlug);
}
const projects = await getProjects();
const match = projects.find(
(project) => toProjectRouteSlug(project) === routeSlug || project.slug === routeSlug,
);
if (!match) {
return getProjectBySlug(routeSlug);
}
// The list response is enough to keep the route alive during a CMS outage;
// use the richer detail response whenever it is available.
return (await getProjectBySlug(match.slug)) ?? match;
});
export type ProjectCategory = { id: string; name: string; count: number };
/** Distinct categories actually present on published projects. */
export function deriveCategories(items: ApiProjectItem[]): ProjectCategory[] {
const byId = new Map<string, ProjectCategory>();
for (const item of items) {
const existing = byId.get(item.categoryId);
@@ -245,6 +322,7 @@ export function deriveCategories(items: ApiPortfolioItem[]): PortfolioCategory[]
export type DisplayProject = {
slug: string;
apiSlug: string;
name: string;
nameLatin: string;
type: string;
@@ -259,7 +337,7 @@ const FALLBACK_IMAGE =
"https://meshkee-storage.sas.amin.parminstorage.ir/meshkee/businesses/9/media/9582b492-970a-4165-b893-7c36ff3d5b4f.jpg";
/**
* Some portfolio bodies carry a broken video-embed paragraph: whoever pasted
* Some project bodies carry a broken video-embed paragraph: whoever pasted
* an Aparat/iframe embed into the CMS's rich text editor ended up with the
* markup HTML-escaped (`&lt;script&gt;` instead of a real `<script>` tag), so
* it renders as visible garbled text instead of a video. React's
@@ -274,9 +352,24 @@ function stripBrokenEmbedArtifacts(html: string): string {
);
}
export function toDisplayProject(item: ApiPortfolioItem): DisplayProject {
/** Stable, readable URL segment derived from the CMS's English project name. */
export function toProjectRouteSlug(item: Pick<ApiProjectItem, "slug" | "titleEn" | "title">): string {
const source = item.titleEn || item.title;
const normalized = source
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || item.slug;
}
export function toDisplayProject(item: ApiProjectItem): DisplayProject {
return {
slug: item.slug,
slug: toProjectRouteSlug(item),
apiSlug: item.slug,
name: item.titleFa || item.title,
nameLatin: item.titleEn || item.titleFa || item.title,
type: item.categoryName,
@@ -289,9 +382,9 @@ export function toDisplayProject(item: ApiPortfolioItem): DisplayProject {
}
/* ---------------------------------------------------------------------------
* Blogs / news — same API family as portfolios (same base URL, same list
* Blogs / news — same API family as projects (same base URL, same list
* envelope, a sibling {slug} + {id}/comments pair), so the item shape below
* mirrors ApiPortfolioItem. `/blogs` additionally takes a `type` filter with
* mirrors ApiProjectItem. `/blogs` additionally takes a `type` filter with
* values "news" | "article" | "blog".
* ------------------------------------------------------------------------- */
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Turns a plain video link into something embeddable.
*
* The portfolio API has no video field, so a link is pasted manually (see
* The projects API has no video field, so a link is pasted manually (see
* `projectVideos` in `src/data/site.ts`) rather than fetched — this just
* figures out how to play whatever link gets pasted there: an Aparat page
* URL, a YouTube URL, or a direct video file.