Redesign core pages and connect dynamic content
@@ -159,7 +159,7 @@ src/
|
|||||||
|
|
||||||
## نکات
|
## نکات
|
||||||
|
|
||||||
- لینکهای ناوبری به مسیرهای واقعی سایت (`/portfolios`، `/news`، …) اشاره میکنند
|
- لینکهای ناوبری به مسیرهای واقعی سایت (`/projects`، `/news`، …) اشاره میکنند
|
||||||
ولی تاکنون فقط صفحه اصلی و درباره ما طراحی شدهاند.
|
ولی تاکنون فقط صفحه اصلی و درباره ما طراحی شدهاند.
|
||||||
- انیمیشنها به `prefers-reduced-motion` احترام میگذارند.
|
- انیمیشنها به `prefers-reduced-motion` احترام میگذارند.
|
||||||
- کروسل پروژهها برای RTL نوشته شده (`scrollLeft` منفی) و با کیبورد و لمس کار میکند.
|
- کروسل پروژهها برای RTL نوشته شده (`scrollLeft` منفی) و با کیبورد و لمس کار میکند.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const nextConfig: NextConfig = {
|
|||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
// Every quality value used across the page. Required from Next.js 16 on.
|
// Every quality value used across the page. Required from Next.js 16 on.
|
||||||
qualities: [70, 72, 75, 80, 82, 88, 90],
|
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) —
|
// already changed once mid-project (parminstorage.ir -> parspack.net) —
|
||||||
// keep both around since either could be what a given item still points to.
|
// keep both around since either could be what a given item still points to.
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -16,6 +16,20 @@ const nextConfig: NextConfig = {
|
|||||||
{ protocol: "https", hostname: "c804387.parspack.net" },
|
{ 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;
|
export default nextConfig;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 515 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 876 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 281 KiB |
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
|
|||||||
import { RaoofiAbout } from "@/components/about/RaoofiAbout";
|
import { RaoofiAbout } from "@/components/about/RaoofiAbout";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { getRecentPortfolios, toDisplayProject } from "@/lib/api";
|
import { getRecentProjects, toDisplayProject } from "@/lib/api";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AboutPage() {
|
export default async function AboutPage() {
|
||||||
const portfolios = await getRecentPortfolios(8);
|
const projects = await getRecentProjects(8);
|
||||||
const displayProjects = portfolios.map(toDisplayProject);
|
const displayProjects = projects.map(toDisplayProject);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
type RevalidateBody = {
|
type RevalidateBody = {
|
||||||
/** Revalidate a fetch cache tag (portfolios, blogs, portfolio:slug, blog:slug, …). */
|
/** Revalidate a fetch cache tag (portfolios, blogs, portfolio:slug, blog:slug, …). */
|
||||||
tag?: string;
|
tag?: string;
|
||||||
/** Revalidate a path (/portfolios/foo, /, …). */
|
/** Revalidate a path (/projects/foo, /, …). */
|
||||||
path?: string;
|
path?: string;
|
||||||
/** Shorthand: portfolios | blogs | all */
|
/** Shorthand: portfolios | blogs | all */
|
||||||
scope?: "portfolios" | "blogs" | "all";
|
scope?: "portfolios" | "blogs" | "all";
|
||||||
@@ -15,7 +15,7 @@ type RevalidateBody = {
|
|||||||
*
|
*
|
||||||
* POST /api/revalidate
|
* POST /api/revalidate
|
||||||
* Header: x-revalidate-secret: <REVALIDATE_SECRET>
|
* 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.
|
* 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") {
|
} else if (scope === "portfolios" || scope === "all") {
|
||||||
revalidateTag("portfolios");
|
revalidateTag("portfolios");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
revalidatePath("/portfolios");
|
revalidatePath("/projects");
|
||||||
revalidatePath("/aboutus");
|
revalidatePath("/aboutus");
|
||||||
revalidatePath("/our-services");
|
revalidatePath("/atelier");
|
||||||
|
revalidatePath("/raoofi-home");
|
||||||
revalidated.push("scope:portfolios");
|
revalidated.push("scope:portfolios");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ import type { Metadata } from "next";
|
|||||||
import { RaoofiAtelier } from "@/components/atelier/RaoofiAtelier";
|
import { RaoofiAtelier } from "@/components/atelier/RaoofiAtelier";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
|
import { getRecentProjects, toDisplayProject } from "@/lib/api";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "آتلیه رئوفی",
|
title: "آتلیه رئوفی",
|
||||||
description: "آتلیه رئوفی؛ خلق فضاهای شخصی، کمیاب و ماندگار با طراحی و اجرای یکپارچه.",
|
description: "آتلیه رئوفی؛ خدمات یکپارچه از نخستین ایده و طراحی تا مدیریت، اجرا و تحویل نهایی.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AtelierPage() {
|
export default async function AtelierPage() {
|
||||||
|
const projects = await getRecentProjects(12);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<RaoofiAtelier />
|
<RaoofiAtelier projects={projects.map(toDisplayProject)} />
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,21 +4,26 @@ import { notFound } from "next/navigation";
|
|||||||
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
|
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
|
import { getDemoNewsItem, getDemoNewsItems } from "@/data/news-demo";
|
||||||
import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api";
|
import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api";
|
||||||
|
|
||||||
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
||||||
|
|
||||||
async function getBlogArticleBySlug(slug: string) {
|
async function getBlogArticleBySlug(slug: string) {
|
||||||
|
const demoItem = getDemoNewsItem(slug, "blog");
|
||||||
|
if (demoItem) return demoItem;
|
||||||
|
|
||||||
const item = await getBlogBySlug(slug);
|
const item = await getBlogBySlug(slug);
|
||||||
if (!item || item.type === "news") {
|
if (!item || item.type === "news") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return item;
|
return toDisplayNewsItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const slugs = await getBlogSlugs();
|
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({
|
export async function generateMetadata({
|
||||||
@@ -29,7 +34,7 @@ export async function generateMetadata({
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const item = await getBlogArticleBySlug(slug);
|
const item = await getBlogArticleBySlug(slug);
|
||||||
if (!item) return {};
|
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({
|
export default async function BlogDetailPage({
|
||||||
@@ -46,7 +51,7 @@ export default async function BlogDetailPage({
|
|||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main>
|
<main>
|
||||||
<NewsArticleDetail
|
<NewsArticleDetail
|
||||||
item={toDisplayNewsItem(item)}
|
item={item}
|
||||||
basePath="/blog"
|
basePath="/blog"
|
||||||
sectionLabel="بلاگ"
|
sectionLabel="بلاگ"
|
||||||
sectionLabelEn="BLOG"
|
sectionLabelEn="BLOG"
|
||||||
|
|||||||
@@ -1,71 +1,36 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { NewsListing } from "@/components/news/NewsListing";
|
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 { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import heroImage from "@/assets/images/hero/hero-2.jpg";
|
import { getDemoNewsItems } from "@/data/news-demo";
|
||||||
import heroImageMobile from "@/assets/images/hero/hero-2-mobile.jpg";
|
import { blogListingPage } from "@/data/site";
|
||||||
import { blogListingPage, newsAndBlogPage } from "@/data/site";
|
|
||||||
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
|
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "بلاگ",
|
title: "مقالات",
|
||||||
description: "فهرست کامل مقالات منتشرشدهی گروه ساختمانی رئوفی.",
|
description: "فهرست کامل مقالات منتشرشدهی گروه ساختمانی رئوفی.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
export const revalidate = 300;
|
||||||
|
|
||||||
export default async function BlogListPage({
|
export default async function BlogListPage() {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ page?: string }>;
|
|
||||||
}) {
|
|
||||||
const { page: pageParam } = await searchParams;
|
|
||||||
const page = Math.max(1, Number(pageParam) || 1);
|
|
||||||
const posts = await getBlogs();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<PageHero
|
<NewsListing
|
||||||
image={heroImage}
|
items={blog}
|
||||||
imageMobile={heroImageMobile}
|
basePath="/blog"
|
||||||
title={blogListingPage.hero.title}
|
emptyMessage={blogListingPage.emptyMessage}
|
||||||
breadcrumb={blogListingPage.hero.breadcrumb}
|
eyebrow="مقالات"
|
||||||
/>
|
title="نگاهی عمیقتر به معماری و زندگی"
|
||||||
|
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
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 { InstagramIcon, MailIcon, PhoneIcon, PinIcon } from "@/components/ContactIcons";
|
||||||
import { ContactForm } from "@/components/contact/ContactForm";
|
import { ContactForm } from "@/components/contact/ContactForm";
|
||||||
import { Reveal } from "@/components/Reveal";
|
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 { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { contact, contactPage } from "@/data/site";
|
import { contact, contactPage } from "@/data/site";
|
||||||
@@ -26,98 +21,85 @@ export default function ContactPage() {
|
|||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main>
|
<main className="contact-page">
|
||||||
<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>
|
|
||||||
|
|
||||||
<section className="bg-[#f1efec] py-24 lg:py-36">
|
<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">
|
<div className="mx-auto max-w-[1400px] px-5 lg:px-10">
|
||||||
{/* Contact info + map */}
|
<div className="grid md:grid-cols-3">
|
||||||
<Reveal className="bg-ink p-7 text-white lg:p-12">
|
<Reveal as="article" className="py-8 md:py-10 md:pe-8">
|
||||||
<SectionLabel
|
<div className="flex items-center gap-[5px] text-ink">
|
||||||
fa={contactPage.info.caption}
|
<PinIcon className="h-7 w-7 shrink-0" strokeWidth={1.8} />
|
||||||
en={contactPage.info.captionLatin}
|
<h2 className="text-xl font-bold text-ink">دفتر مرکزی</h2>
|
||||||
tone="dark"
|
</div>
|
||||||
align="start"
|
<p className="mt-4 max-w-sm leading-[2.1] text-muted">{contact.address}</p>
|
||||||
className="mb-10"
|
<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">
|
<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">
|
||||||
<li className="flex gap-4">
|
<div className="flex items-center gap-[5px] text-ink">
|
||||||
<PinIcon className="mt-1 h-5 w-5 shrink-0 text-gold" />
|
<PhoneIcon className="h-7 w-7 shrink-0" strokeWidth={1.8} />
|
||||||
<p className="leading-[2] text-white/75">{contact.address}</p>
|
<h2 className="text-xl font-bold text-ink">تماس با ما</h2>
|
||||||
</li>
|
</div>
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
{contact.phones.map((phone, i) => (
|
{contact.phones.map((phone, i) => (
|
||||||
<li key={phone}>
|
<li key={phone}>
|
||||||
<Link
|
<Link href={`tel:${contact.phoneLinks[i]}`} className="border-b border-transparent pb-1 text-muted transition-colors hover:border-ink/50 hover:text-ink">
|
||||||
href={`tel:${contact.phoneLinks[i]}`}
|
{phone}
|
||||||
className="group flex items-center gap-4 text-white/75 transition-colors duration-300 hover:text-gold"
|
</Link>
|
||||||
>
|
</li>
|
||||||
<PhoneIcon className="h-5 w-5 shrink-0 text-gold" />
|
))}
|
||||||
<span className="latin">{phone}</span>
|
<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>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
</ul>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<li>
|
<Reveal as="article" delay={140} className="py-8 md:ps-8 md:py-10">
|
||||||
<Link
|
<div className="flex items-center gap-[5px] text-ink">
|
||||||
href={`mailto:${contact.email}`}
|
<InstagramIcon className="h-7 w-7 shrink-0" strokeWidth={1.8} />
|
||||||
className="group flex items-center gap-4 text-white/75 transition-colors duration-300 hover:text-gold"
|
<h2 className="text-xl font-bold text-ink">شبکههای اجتماعی</h2>
|
||||||
>
|
</div>
|
||||||
<MailIcon className="h-5 w-5 shrink-0 text-gold" />
|
<p className="mt-4 leading-[2.1] text-muted">برای آشنایی با تازهترین خبرها و پروژههای رئوفی، ما را دنبال کنید.</p>
|
||||||
<span className="latin">{contact.email}</span>
|
<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">
|
||||||
</Link>
|
{contact.instagram}
|
||||||
</li>
|
</Link>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
<li>
|
<Reveal delay={100} className="mt-12 h-[320px] overflow-hidden bg-white sm:h-[420px] lg:mt-16 lg:h-[520px]">
|
||||||
<Link
|
<iframe
|
||||||
href={contact.instagramUrl}
|
src={MAP_EMBED_SRC}
|
||||||
className="group flex items-center gap-4 text-white/75 transition-colors duration-300 hover:text-gold"
|
title="نقشهی موقعیت گروه ساختمانی رئوفی"
|
||||||
>
|
loading="lazy"
|
||||||
<InstagramIcon className="h-5 w-5 shrink-0 text-gold" />
|
allowFullScreen
|
||||||
<span className="latin">{contact.instagram}</span>
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
</Link>
|
className="h-full w-full grayscale-[25%]"
|
||||||
</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>
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Form */}
|
<section className="bg-paper py-24 lg:py-36">
|
||||||
<Reveal delay={90} className="bg-white p-7 lg:p-12">
|
<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">
|
||||||
<div>
|
<Reveal>
|
||||||
<SectionLabel
|
<h2 className="max-w-md text-[clamp(1.8rem,3vw,2.75rem)] leading-[1.5] font-bold text-ink">
|
||||||
fa={contactPage.form.caption}
|
{contactPage.form.title}
|
||||||
en={contactPage.form.captionLatin}
|
</h2>
|
||||||
align="start"
|
<p className="mt-6 max-w-md leading-[2.1] text-muted">
|
||||||
className="mb-8"
|
اطلاعات خود را ثبت کنید تا همکاران ما در اولین فرصت با شما تماس بگیرند.
|
||||||
/>
|
</p>
|
||||||
<ContactForm />
|
</Reveal>
|
||||||
</div>
|
<Reveal delay={90}>
|
||||||
|
<ContactForm />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -98,6 +98,10 @@
|
|||||||
unicode-bidi: isolate;
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-page .kicker {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
/* Thin gold rule that precedes every section kicker. */
|
/* Thin gold rule that precedes every section kicker. */
|
||||||
.kicker {
|
.kicker {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -114,7 +118,7 @@
|
|||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rich text rendered from the portfolio API's mainTextHtml field. */
|
/* Rich text rendered from the projects API's mainTextHtml field. */
|
||||||
.cms-body {
|
.cms-body {
|
||||||
max-width: 738px;
|
max-width: 738px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import heroImage from "@/assets/images/hero/hero-1.jpg";
|
import { NewsListing, type NewsListingItem } from "@/components/news/NewsListing";
|
||||||
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 { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
|
import { getDemoNewsItems } from "@/data/news-demo";
|
||||||
import { newsAndBlogPage } from "@/data/site";
|
import { newsAndBlogPage } from "@/data/site";
|
||||||
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
|
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
|
||||||
|
|
||||||
@@ -19,102 +12,32 @@ export const metadata: Metadata = {
|
|||||||
description: "آخرین اخبار، رویدادها و مقالات گروه ساختمانی رئوفی.",
|
description: "آخرین اخبار، رویدادها و مقالات گروه ساختمانی رئوفی.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
export const revalidate = 300;
|
||||||
|
|
||||||
export default async function NewsAndBlogPage() {
|
export default async function NewsAndBlogPage() {
|
||||||
const posts = await getBlogs();
|
const posts = await getBlogs();
|
||||||
const news = posts.filter((p) => p.type === "news").map(toDisplayNewsItem);
|
const items: NewsListingItem[] = posts.length
|
||||||
const blog = posts.filter((p) => p.type !== "news").map(toDisplayNewsItem);
|
? posts.map((post) => ({
|
||||||
|
...toDisplayNewsItem(post),
|
||||||
const hasNews = news.length > 0;
|
href: `${post.type === "news" ? "/news" : "/blog"}/${post.slug}`,
|
||||||
const hasBlog = blog.length > 0;
|
}))
|
||||||
const hasNothing = !hasNews && !hasBlog;
|
: getDemoNewsItems().map((post) => ({
|
||||||
|
...post,
|
||||||
|
href: `${post.kind === "news" ? "/news" : "/blog"}/${post.slug}`,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<PageHero
|
<NewsListing
|
||||||
image={heroImage}
|
items={items}
|
||||||
imageMobile={heroImageMobile}
|
basePath="/news"
|
||||||
title={newsAndBlogPage.hero.title}
|
emptyMessage={newsAndBlogPage.emptyMessage}
|
||||||
breadcrumb={newsAndBlogPage.hero.breadcrumb}
|
eyebrow="مقالات و اخبار"
|
||||||
/>
|
title="روایتهایی پشت هر چشمانداز"
|
||||||
|
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,21 +4,26 @@ import { notFound } from "next/navigation";
|
|||||||
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
|
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
|
import { getDemoNewsItem, getDemoNewsItems } from "@/data/news-demo";
|
||||||
import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api";
|
import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api";
|
||||||
|
|
||||||
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
||||||
|
|
||||||
async function getNewsBySlug(slug: string) {
|
async function getNewsBySlug(slug: string) {
|
||||||
|
const demoItem = getDemoNewsItem(slug, "news");
|
||||||
|
if (demoItem) return demoItem;
|
||||||
|
|
||||||
const item = await getBlogBySlug(slug);
|
const item = await getBlogBySlug(slug);
|
||||||
if (!item || item.type !== "news") {
|
if (!item || item.type !== "news") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return item;
|
return toDisplayNewsItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const slugs = await getBlogSlugs("news");
|
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({
|
export async function generateMetadata({
|
||||||
@@ -29,7 +34,7 @@ export async function generateMetadata({
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const item = await getNewsBySlug(slug);
|
const item = await getNewsBySlug(slug);
|
||||||
if (!item) return {};
|
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({
|
export default async function NewsDetailPage({
|
||||||
@@ -46,7 +51,7 @@ export default async function NewsDetailPage({
|
|||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main>
|
<main>
|
||||||
<NewsArticleDetail
|
<NewsArticleDetail
|
||||||
item={toDisplayNewsItem(item)}
|
item={item}
|
||||||
basePath="/news"
|
basePath="/news"
|
||||||
sectionLabel="اخبار"
|
sectionLabel="اخبار"
|
||||||
sectionLabelEn="NEWS"
|
sectionLabelEn="NEWS"
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { NewsListing } from "@/components/news/NewsListing";
|
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 { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import heroImage from "@/assets/images/hero/hero-1.jpg";
|
import { getDemoNewsItems } from "@/data/news-demo";
|
||||||
import heroImageMobile from "@/assets/images/hero/hero-1-mobile.jpg";
|
import { newsListingPage } from "@/data/site";
|
||||||
import { newsAndBlogPage, newsListingPage } from "@/data/site";
|
|
||||||
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
|
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -16,56 +12,24 @@ export const metadata: Metadata = {
|
|||||||
description: "فهرست کامل اخبار و رویدادهای منتشرشدهی گروه ساختمانی رئوفی.",
|
description: "فهرست کامل اخبار و رویدادهای منتشرشدهی گروه ساختمانی رئوفی.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
export const revalidate = 300;
|
||||||
|
|
||||||
export default async function NewsListPage({
|
export default async function NewsListPage() {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ page?: string }>;
|
|
||||||
}) {
|
|
||||||
const { page: pageParam } = await searchParams;
|
|
||||||
const page = Math.max(1, Number(pageParam) || 1);
|
|
||||||
const posts = await getBlogs("news");
|
const posts = await getBlogs("news");
|
||||||
const news = posts.map(toDisplayNewsItem);
|
const news = posts.length ? posts.map(toDisplayNewsItem) : getDemoNewsItems("news");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<PageHero
|
<NewsListing
|
||||||
image={heroImage}
|
items={news}
|
||||||
imageMobile={heroImageMobile}
|
basePath="/news"
|
||||||
title={newsListingPage.hero.title}
|
emptyMessage={newsListingPage.emptyMessage}
|
||||||
breadcrumb={newsListingPage.hero.breadcrumb}
|
eyebrow="اخبار"
|
||||||
/>
|
title="روایتهایی پشت هر چشمانداز"
|
||||||
|
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
|
||||||
import { ProjectGalleryLightbox } from "@/components/portfolios/ProjectGalleryLightbox";
|
import { ProjectGalleryLightbox } from "@/components/projects/ProjectGalleryLightbox";
|
||||||
import { ProjectVideo } from "@/components/portfolios/ProjectVideo";
|
import { ProjectVideo } from "@/components/projects/ProjectVideo";
|
||||||
import { Reveal } from "@/components/Reveal";
|
import { Reveal } from "@/components/Reveal";
|
||||||
import { PageCta } from "@/components/shared/PageCta";
|
import { PageCta } from "@/components/shared/PageCta";
|
||||||
import { PageHero } from "@/components/shared/PageHero";
|
import { PageHero } from "@/components/shared/PageHero";
|
||||||
import { SectionLabel } from "@/components/shared/SectionLabel";
|
import { SectionLabel } from "@/components/shared/SectionLabel";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { portfoliosPage, projectVideos } from "@/data/site";
|
import { projectsPage, projectVideos } from "@/data/site";
|
||||||
import { getPortfolioBySlug, getPortfolioSlugs, toDisplayProject } from "@/lib/api";
|
import { getProjectByRouteSlug, getProjectSlugs, toDisplayProject } from "@/lib/api";
|
||||||
|
|
||||||
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const slugs = await getPortfolioSlugs();
|
const slugs = await getProjectSlugs();
|
||||||
return slugs.map((slug) => ({ slug }));
|
return slugs.map((slug) => ({ slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const item = await getPortfolioBySlug(slug);
|
const item = await getProjectByRouteSlug(slug);
|
||||||
if (!item) return {};
|
if (!item) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -37,22 +37,24 @@ export async function generateMetadata({
|
|||||||
/**
|
/**
|
||||||
* Structurally inspired by binghatti.com's project pages (hero, in-page jump
|
* Structurally inspired by binghatti.com's project pages (hero, in-page jump
|
||||||
* nav, gallery grid, closing CTA) — but scoped strictly to what the meshkee
|
* 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;
|
* 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.
|
* 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,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const item = await getPortfolioBySlug(slug);
|
const item = await getProjectByRouteSlug(slug);
|
||||||
if (!item) notFound();
|
if (!item) notFound();
|
||||||
|
|
||||||
const project = toDisplayProject(item);
|
const project = toDisplayProject(item);
|
||||||
|
if (slug !== project.slug) redirect(`/projects/${project.slug}`);
|
||||||
|
|
||||||
const hasGallery = project.gallery.length > 0;
|
const hasGallery = project.gallery.length > 0;
|
||||||
const videoUrl = projectVideos[project.slug];
|
const videoUrl = projectVideos[project.apiSlug];
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: "overview", label: "معرفی پروژه" },
|
{ id: "overview", label: "معرفی پروژه" },
|
||||||
@@ -76,8 +78,8 @@ export default async function PortfolioDetailPage({
|
|||||||
title={project.name}
|
title={project.name}
|
||||||
breadcrumb={[
|
breadcrumb={[
|
||||||
{ label: "صفحه اصلی", href: "/" },
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
{ label: "نمونه کارها", href: "/portfolios" },
|
{ label: "پروژهها", href: "/projects" },
|
||||||
{ label: project.name, href: `/portfolios/${project.slug}` },
|
{ label: project.name, href: `/projects/${project.slug}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -140,9 +142,9 @@ export default async function PortfolioDetailPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PageCta
|
<PageCta
|
||||||
caption={portfoliosPage.cta.caption}
|
caption={projectsPage.cta.caption}
|
||||||
title={portfoliosPage.cta.title}
|
title={projectsPage.cta.title}
|
||||||
action={portfoliosPage.cta.action}
|
action={projectsPage.cta.action}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,37 @@
|
|||||||
/** Shared icon set for contact details — used by the footer and the contact page. */
|
/** 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 (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
|
||||||
<path
|
<path
|
||||||
d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11Z"
|
d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11Z"
|
||||||
stroke="currentColor"
|
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>
|
</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 (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
|
||||||
<path
|
<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"
|
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"
|
stroke="currentColor"
|
||||||
strokeWidth="1.4"
|
strokeWidth={strokeWidth}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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 (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
|
<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" />
|
<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="1.4" />
|
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth={strokeWidth} />
|
||||||
<circle cx="17" cy="7" r="1.1" fill="currentColor" />
|
<circle cx="17" cy="7" r="1.1" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function Hero() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<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"
|
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"
|
||||||
>
|
>
|
||||||
مشاهده پروژهها
|
مشاهده پروژهها
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function Projects({ projects }: { projects: DisplayProject[] }) {
|
|||||||
<div className="flex flex-wrap items-end justify-between gap-8">
|
<div className="flex flex-wrap items-end justify-between gap-8">
|
||||||
<div>
|
<div>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionLabel fa="نمونه کارها" en="PORTFOLIO" align="start" className="mb-6" />
|
<SectionLabel fa="پروژهها" en="PROJECTS" align="start" className="mb-6" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={90}>
|
<Reveal delay={90}>
|
||||||
<h2 className="text-[1.625rem] leading-[1.25] font-extrabold whitespace-nowrap text-white lg:text-[2.625rem]">
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<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"
|
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}
|
key={project.slug}
|
||||||
className="w-[78vw] shrink-0 snap-start sm:w-[46vw] lg:w-[30vw] xl:w-[23rem]"
|
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">
|
<div className="relative aspect-[3/4] overflow-hidden bg-ink-3">
|
||||||
<Image
|
<Image
|
||||||
src={project.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">
|
<div className="mx-auto mt-10 max-w-[1400px] px-5 sm:hidden lg:px-10">
|
||||||
<Link
|
<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"
|
className="block border border-white/25 py-3.5 text-center text-sm text-white transition-colors hover:border-gold hover:bg-gold"
|
||||||
>
|
>
|
||||||
مشاهده همه پروژهها
|
مشاهده همه پروژهها
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function Services() {
|
|||||||
<div className="flex flex-wrap items-end justify-between gap-8">
|
<div className="flex flex-wrap items-end justify-between gap-8">
|
||||||
<div>
|
<div>
|
||||||
<Reveal>
|
<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>
|
||||||
<Reveal delay={90}>
|
<Reveal delay={90}>
|
||||||
<h2 className="text-[1.625rem] leading-[1.25] font-extrabold whitespace-nowrap text-ink lg:text-[2.625rem]">
|
<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 (
|
return (
|
||||||
<Reveal as="li" key={service.slug} delay={(i % 3) * 90} className="bg-paper">
|
<Reveal as="li" key={service.slug} delay={(i % 3) * 90} className="bg-paper">
|
||||||
<Link
|
<Link
|
||||||
href={`/our-services#${service.slug}`}
|
href={`/atelier#${service.slug}`}
|
||||||
className="group relative block h-full overflow-hidden p-9 lg:p-11"
|
className="group relative block h-full overflow-hidden p-9 lg:p-11"
|
||||||
>
|
>
|
||||||
{/* Ink panel wipes upward on hover. */}
|
{/* Ink panel wipes upward on hover. */}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ export function SiteFooter() {
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={contact.instagramUrl}
|
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}`}
|
aria-label={`اینستاگرام ${site.name}`}
|
||||||
>
|
>
|
||||||
<InstagramIcon />
|
<InstagramIcon className="h-5 w-5 shrink-0 text-gold transition-colors duration-300 group-hover:text-white" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export function SiteFooter() {
|
|||||||
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
|
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
|
||||||
>
|
>
|
||||||
<InstagramIcon />
|
<InstagramIcon />
|
||||||
<span className="latin">{contact.instagram}</span>
|
<span>{contact.instagram}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -83,7 +83,7 @@ export function SiteFooter() {
|
|||||||
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
|
className="flex items-center gap-3 text-sm transition-colors duration-300 hover:text-white"
|
||||||
>
|
>
|
||||||
<MailIcon />
|
<MailIcon />
|
||||||
<span className="latin">{contact.email}</span>
|
<span>{contact.email}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function RaoofiAbout({ projects: _projects }: { projects: DisplayProject[
|
|||||||
<div className="oas-timeline-caption">
|
<div className="oas-timeline-caption">
|
||||||
<small>{project.type} / {String(index + 1).padStart(2, "0")}</small>
|
<small>{project.type} / {String(index + 1).padStart(2, "0")}</small>
|
||||||
<h3>{project.name}</h3>
|
<h3>{project.name}</h3>
|
||||||
{project.slug && <Link href={`/portfolios/${project.slug}`}>مشاهدهٔ پروژه</Link>}
|
{project.slug && <Link href={`/projects/${project.slug}`}>مشاهدهٔ پروژه</Link>}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
|
|||||||
@@ -1,127 +1,77 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
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 type { Swiper as SwiperInstance } from "swiper";
|
||||||
import { Swiper, SwiperSlide } from "swiper/react";
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
import "swiper/css";
|
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";
|
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 = [
|
export function RaoofiAtelier({ projects }: { projects: DisplayProject[] }) {
|
||||||
{ image: media("collection-1.webp"), title: "اقامتگاهی فراتر از تعریف", subtitle: "فضایی برای یک زندگی یگانه" },
|
const projectSwiper = useRef<SwiperInstance | null>(null);
|
||||||
{ image: media("collection-2.webp"), title: "خانهای در اوج آسمان", subtitle: "روایتی شخصی از آرامش" },
|
const portfolio = projects.length
|
||||||
{ image: media("collection-3.webp"), title: "شاهکاری برای یک نفر", subtitle: "طراحیشده برای ماندن" },
|
? projects.slice(0, 7).map((project) => ({ slug: project.slug, name: project.name, type: project.type, image: project.image }))
|
||||||
];
|
: fallbackProjects;
|
||||||
|
|
||||||
export function RaoofiAtelier() {
|
|
||||||
const galleryRef = useRef<SwiperInstance | null>(null);
|
|
||||||
const [activeSlide, setActiveSlide] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => entries.forEach((entry) => entry.isIntersecting && entry.target.classList.add("is-visible")),
|
(entries) => entries.forEach((entry) => entry.isIntersecting && entry.target.classList.add("is-visible")),
|
||||||
{ threshold: 0.12 },
|
{ 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 () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="atelier-page" dir="rtl">
|
<main className="osa-page" dir="rtl">
|
||||||
<section className="atelier-hero">
|
<section className="osa-hero">
|
||||||
<video autoPlay muted loop playsInline preload="metadata" poster={media("hero-poster.jpg")}>
|
<video autoPlay muted loop playsInline preload="metadata"><source src={`${base}/hero-desktop.mp4`} type="video/mp4" /></video>
|
||||||
<source src={media("hero-mobile.mp4")} media="(max-width:700px)" type="video/mp4" />
|
<div className="osa-overlay" />
|
||||||
<source src={media("hero-desktop.mp4")} type="video/mp4" />
|
<div className="osa-hero-mark"><strong>RAOOFI</strong><span>Atelier</span></div>
|
||||||
</video>
|
<span className="osa-scroll">⌄</span>
|
||||||
<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>
|
||||||
|
|
||||||
<section className="atelier-intro atelier-reveal">
|
<section className="osa-overview">
|
||||||
<span>آتلیه رئوفی</span>
|
<div className="osa-heading osa-reveal"><span>نگاه کلی</span><h1>چشمانداز شما را<br />به واقعیت تبدیل میکنیم</h1><div><p>هنر و تجربهٔ گروه رئوفی را به طراحی، تأمین، مدیریت و اجرای کامل پروژه پیوند میدهیم تا هر فضا بازتابی دقیق از سبک زندگی و خواستههای شما باشد.</p><p>با همراهی معماران، هنرمندان و متخصصان برجسته، فضاهایی منحصربهفرد خلق میکنیم که زیبایی، عملکرد و کیفیت ماندگار را در کنار هم قرار میدهند.</p></div></div>
|
||||||
<h1>همکاری در بالاترین سطح</h1>
|
<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>
|
||||||
<div>
|
</section>
|
||||||
<p>آتلیه رئوفی برای کسانی شکل گرفته است که معیارهای زندگی خود را تعریف میکنند و میخواهند چشمانداز شخصیشان در تمام جزئیات فضا دیده شود.</p>
|
|
||||||
<p>این مسیر با همراهی معماران، طراحان و استادکاران برجسته، کمیابترین ایدهها را به آثاری دقیق و ماندگار تبدیل میکند.</p>
|
<section className="osa-services">
|
||||||
<p>ریشه در هنر، مهندسی و ساخت سفارشی دارد؛ تعریفی امروزی از کیفیت که عمیقاً شخصی و همواره یگانه است.</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="atelier-wide-image atelier-reveal">
|
<section className="osa-portfolio">
|
||||||
<Image src={media("story-1.webp")} alt="فضای معماری اختصاصی" fill sizes="100vw" />
|
<div className="osa-heading osa-reveal"><span>نمونه پروژهها</span><h2>از طرح تا واقعیت</h2><div><p>پروژههای ما گسترهٔ خدمات، دقت اجرا و کیفیتی را نشان میدهند که از یک مسیر یکپارچهٔ طراحی و مدیریت شکل گرفته است.</p></div></div>
|
||||||
</section>
|
<div className="osa-project-stage osa-reveal">
|
||||||
|
<Swiper dir="ltr" loop={portfolio.length > 1} speed={850} slidesPerView={1} grabCursor onSwiper={(swiper) => { projectSwiper.current = swiper; }}>
|
||||||
<section className="atelier-intro atelier-intro--second atelier-reveal">
|
{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>)}
|
||||||
<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>
|
</Swiper>
|
||||||
<div className="atelier-gallery__progress" aria-hidden="true">
|
<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>
|
||||||
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="atelier-manifesto atelier-reveal">
|
<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>
|
||||||
<p>شوقِ جانبخشیدن به رویاها و چشماندازهای کسانی که آینده را میسازند، تعهد استوار آتلیه رئوفی را به تصور امر خارقالعاده و تبدیل آن به واقعیت شکل میدهد.</p>
|
|
||||||
<p>آثار منحصربهفرد و معیارساز آتلیه رئوفی، خانهها و پناهگاههایی برای اندیشمندان، رهبران و سازندگان فرداست؛ فضاهایی برای کسانی که جسارت متفاوتبودن دارند.</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%)}
|
.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%)}
|
||||||
.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}
|
.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}
|
||||||
.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}
|
.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}
|
||||||
.atelier-wide-image{position:relative;height:min(68vw,850px);overflow:hidden;background:#ddd}.atelier-wide-image img,.atelier-editorial img{object-fit:cover}
|
.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}
|
||||||
.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}
|
.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}
|
||||||
.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}
|
.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}
|
||||||
.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}
|
@keyframes osa-bob{50%{transform:translateY(8px)}}
|
||||||
.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){.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(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){.osa-reveal{opacity:1;transform:none}.osa-scroll{animation:none}}
|
||||||
@media(prefers-reduced-motion:reduce){.atelier-reveal{opacity:1;transform:none}.atelier-scroll{animation:none}}
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function validate(form: FormState): Errors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fieldClass =
|
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() {
|
export function ContactForm() {
|
||||||
const [form, setForm] = useState<FormState>(EMPTY);
|
const [form, setForm] = useState<FormState>(EMPTY);
|
||||||
@@ -135,7 +135,7 @@ export function ContactForm() {
|
|||||||
onChange={update("phone")}
|
onChange={update("phone")}
|
||||||
placeholder={contactPage.form.phonePlaceholder}
|
placeholder={contactPage.form.phonePlaceholder}
|
||||||
aria-invalid={!!errors.phone}
|
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>}
|
{errors.phone && <p className="mt-1.5 text-xs text-red-600">{errors.phone}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +153,7 @@ export function ContactForm() {
|
|||||||
onChange={update("email")}
|
onChange={update("email")}
|
||||||
placeholder={contactPage.form.emailPlaceholder}
|
placeholder={contactPage.form.emailPlaceholder}
|
||||||
aria-invalid={!!errors.email}
|
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>}
|
{errors.email && <p className="mt-1.5 text-xs text-red-600">{errors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ const partners = [
|
|||||||
|
|
||||||
const menu = [
|
const menu = [
|
||||||
{ label: "صفحه اصلی", href: "/" },
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
{ label: "پروژهها", href: "/portfolios" },
|
{ label: "پروژهها", href: "/projects" },
|
||||||
{ label: "خدمات ما", href: "/our-services" },
|
|
||||||
{ label: "آتلیه رئوفی", href: "/atelier" },
|
{ label: "آتلیه رئوفی", href: "/atelier" },
|
||||||
|
{ label: "رئوفی هوم", href: "/raoofi-home" },
|
||||||
{ label: "داستان ما", href: "/aboutus" },
|
{ label: "داستان ما", href: "/aboutus" },
|
||||||
{ label: "اخبار", href: "/news-and-blog" },
|
{ label: "اخبار", href: "/news-and-blog" },
|
||||||
{ label: "تماس با ما", href: "/contactus" },
|
{ 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>
|
<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>
|
</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">
|
<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>
|
<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>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="om-story om-container om-reveal">
|
||||||
<div className="om-story-intro">
|
<div className="om-story-intro">
|
||||||
|
|||||||
@@ -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 { 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";
|
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({
|
export function NewsArticleDetail({
|
||||||
item,
|
item,
|
||||||
basePath,
|
basePath,
|
||||||
sectionLabel,
|
sectionLabel,
|
||||||
sectionLabelEn,
|
|
||||||
}: {
|
}: {
|
||||||
item: DisplayNewsItem;
|
item: DisplayNewsItem;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
sectionLabel: string;
|
sectionLabel: string;
|
||||||
sectionLabelEn: string;
|
sectionLabelEn: string;
|
||||||
}) {
|
}) {
|
||||||
const hasGallery = item.gallery.length > 0;
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ id: "overview", label: "معرفی" },
|
|
||||||
...(hasGallery ? [{ id: "gallery", label: "گالری تصاویر" }] : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<article className="bg-paper text-ink">
|
||||||
<PageHero
|
<header className="px-5 pb-20 pt-[125px] lg:px-12 lg:pb-24 lg:pt-[132px]">
|
||||||
image={item.image}
|
<div className="mx-auto max-w-[1400px]">
|
||||||
title={item.title}
|
<div className="flex items-center justify-between gap-5 text-[11px] text-[#9b896d] lg:text-[12px]">
|
||||||
breadcrumb={[
|
<Link
|
||||||
{ label: "صفحه اصلی", href: "/" },
|
href={basePath}
|
||||||
{ label: "مقالات و اخبار", href: "/news-and-blog" },
|
className="bg-[#f1efec] px-4 py-2 text-ink transition-colors duration-300 hover:bg-ink hover:text-white"
|
||||||
{ label: sectionLabel, href: basePath },
|
>
|
||||||
{ label: item.title, href: `${basePath}/${item.slug}` },
|
{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>
|
<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>
|
</Reveal>
|
||||||
|
|
||||||
{item.bodyHtml && (
|
|
||||||
<Reveal delay={90}>
|
|
||||||
<div className="cms-body cms-body--editorial" dangerouslySetInnerHTML={{ __html: item.bodyHtml }} />
|
|
||||||
</Reveal>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
{hasGallery && (
|
{item.gallery.length > 0 && (
|
||||||
<section id="gallery" className="scroll-mt-20 bg-[#f1efec] py-24 lg:py-32">
|
<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">
|
||||||
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
|
{item.gallery.slice(0, 3).map((image, index) => (
|
||||||
<Reveal>
|
<Reveal
|
||||||
<SectionLabel fa="گالری تصاویر" en="GALLERY" className="mb-14" />
|
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>
|
</Reveal>
|
||||||
|
))}
|
||||||
<ProjectGalleryLightbox images={item.gallery} alt={item.title} />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PageCta
|
<section className="pb-24 text-center lg:pb-32">
|
||||||
caption={newsAndBlogPage.cta.caption}
|
<p className="mb-6 text-sm text-ink">اشتراکگذاری خبر</p>
|
||||||
title={newsAndBlogPage.cta.title}
|
<NewsShareActions title={item.title} />
|
||||||
action={newsAndBlogPage.cta.action}
|
</section>
|
||||||
/>
|
</article>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Reveal } from "@/components/Reveal";
|
import { Reveal } from "@/components/Reveal";
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
|
||||||
import type { DisplayNewsItem } from "@/lib/api";
|
import type { DisplayNewsItem } from "@/lib/api";
|
||||||
|
|
||||||
const PAGE_SIZE = 9;
|
const PAGE_SIZE = 9;
|
||||||
|
|
||||||
/**
|
export type NewsListingItem = DisplayNewsItem & { href?: string };
|
||||||
* 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 function NewsListing({
|
export function NewsListing({
|
||||||
items,
|
items,
|
||||||
page,
|
|
||||||
basePath,
|
basePath,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
eyebrow = "مقالات و اخبار",
|
||||||
|
title = "روایتهایی پشت هر چشمانداز",
|
||||||
}: {
|
}: {
|
||||||
items: DisplayNewsItem[];
|
items: NewsListingItem[];
|
||||||
page: number;
|
|
||||||
basePath: string;
|
basePath: string;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
const currentPage = Math.min(totalPages, Math.max(1, page));
|
const visibleItems = items.slice(0, visibleCount);
|
||||||
const pageItems = items.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
const hasMore = visibleCount < items.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
|
<section className="bg-paper pb-24 pt-[148px] lg:pb-36 lg:pt-[190px]">
|
||||||
{pageItems.length === 0 ? (
|
<div className="mx-auto max-w-[1400px] px-5 lg:px-12">
|
||||||
<p className="py-16 text-center text-muted">{emptyMessage}</p>
|
<header className="mb-20 text-center lg:mb-28">
|
||||||
) : (
|
<p className="text-[12px] font-bold tracking-[0.12em] text-[#9b896d]">{eyebrow}</p>
|
||||||
<ul className="grid gap-7 md:grid-cols-2 lg:gap-10">
|
<h1 className="mx-auto mt-7 max-w-[1000px] text-[clamp(16px,2.5vw,33px)] leading-[1.65] font-normal text-ink">
|
||||||
{pageItems.map((item, i) => (
|
{title}
|
||||||
<Reveal as="li" key={item.slug} delay={(i % 6) * 70}>
|
</h1>
|
||||||
<Link href={`${basePath}/${item.slug}`} className="group block h-full">
|
</header>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col justify-between p-7 lg:p-8">
|
{visibleItems.length === 0 ? (
|
||||||
<time className="flex items-center gap-2.5 text-[0.8rem] text-muted">
|
<p className="py-16 text-center text-muted">{emptyMessage}</p>
|
||||||
<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" />
|
<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">
|
||||||
<path d="M3.5 10h17M8 3.5V6M16 3.5V6" stroke="currentColor" strokeWidth="1.4" />
|
{visibleItems.map((item, i) => {
|
||||||
</svg>
|
const href = item.href ?? `${basePath}/${item.slug}`;
|
||||||
{item.date}
|
|
||||||
</time>
|
|
||||||
|
|
||||||
<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">
|
return (
|
||||||
{item.title}
|
<Reveal as="li" key={`${href}-${item.slug}`} delay={(i % 6) * 55}>
|
||||||
</h3>
|
<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">
|
<div className="pt-4 text-right">
|
||||||
ادامه مطلب
|
<time className="text-[11px] text-muted/75">{item.date}</time>
|
||||||
<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">
|
<h2 className="mt-1.5 min-h-[3.4em] text-[17px] leading-[1.7] font-medium text-ink">
|
||||||
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
|
<Link href={href} className="transition-colors duration-300 hover:text-muted">
|
||||||
</svg>
|
{item.title}
|
||||||
</span>
|
</Link>
|
||||||
</div>
|
</h2>
|
||||||
</article>
|
|
||||||
</Link>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Pagination
|
<Link
|
||||||
page={currentPage}
|
href={href}
|
||||||
totalPages={totalPages}
|
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"
|
||||||
hrefFor={(p) => `${basePath}?page=${p}`}
|
>
|
||||||
/>
|
مشاهده خبر
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}}
|
|
||||||
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import type { DisplayProject } from "@/lib/api";
|
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.
|
* carousel. Dots below, arrows to the side — both RTL-aware.
|
||||||
*/
|
*/
|
||||||
export function ProjectGallery({ projects }: { projects: DisplayProject[] }) {
|
export function ProjectGallery({ projects }: { projects: DisplayProject[] }) {
|
||||||
@@ -61,7 +61,7 @@ export function ProjectGallery({ projects }: { projects: DisplayProject[] }) {
|
|||||||
key={project.slug}
|
key={project.slug}
|
||||||
className="w-[86vw] shrink-0 snap-center sm:w-[62vw] lg:w-[46vw] xl:w-[40rem]"
|
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>
|
<figure>
|
||||||
<div className="relative aspect-[5/4] overflow-hidden bg-ink">
|
<div className="relative aspect-[5/4] overflow-hidden bg-ink">
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,9 +25,9 @@ export const site = {
|
|||||||
|
|
||||||
export const nav = [
|
export const nav = [
|
||||||
{ label: "صفحه اصلی", href: "/" },
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
{ label: "خدمات ما", href: "/our-services" },
|
|
||||||
{ label: "آتلیه رئوفی", href: "/atelier" },
|
{ label: "آتلیه رئوفی", href: "/atelier" },
|
||||||
{ label: "پروژه ها", href: "/portfolios" },
|
{ label: "رئوفی هوم", href: "/raoofi-home" },
|
||||||
|
{ label: "پروژهها", href: "/projects" },
|
||||||
{ label: "مقالات و اخبار", href: "/news-and-blog" },
|
{ label: "مقالات و اخبار", href: "/news-and-blog" },
|
||||||
{ label: "درباره ما", href: "/aboutus" },
|
{ label: "درباره ما", href: "/aboutus" },
|
||||||
{ label: "تماس با ما", href: "/contactus" },
|
{ label: "تماس با ما", href: "/contactus" },
|
||||||
@@ -196,7 +196,7 @@ export const aboutPage = {
|
|||||||
* Services page
|
* Services page
|
||||||
*
|
*
|
||||||
* The live page (raoofigroup.com/our-services) carries only the six service
|
* 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
|
* descriptions and every card links back to "/". The `blurb` lines below were
|
||||||
* written for this rebuild: each one describes what the discipline itself
|
* 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
|
* covers, so nothing is claimed about the company that its own site does not
|
||||||
@@ -206,18 +206,18 @@ export const aboutPage = {
|
|||||||
export const servicesPage = {
|
export const servicesPage = {
|
||||||
hero: {
|
hero: {
|
||||||
caption: "OUR SERVICES",
|
caption: "OUR SERVICES",
|
||||||
title: "خدمات ما",
|
title: "آتلیه رئوفی",
|
||||||
breadcrumb: [
|
breadcrumb: [
|
||||||
{ label: "صفحه اصلی", href: "/" },
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
{ label: "خدمات ما", href: "/our-services" },
|
{ label: "آتلیه رئوفی", href: "/atelier" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
overview: {
|
overview: {
|
||||||
caption: "خدمات ما",
|
caption: "آتلیه رئوفی",
|
||||||
captionLatin: "SERVICES",
|
captionLatin: "SERVICES",
|
||||||
/** Headline as written on the live page. */
|
/** Headline as written on the live page. */
|
||||||
title: "با خدمات ما بیشتر آشنا شوید...",
|
title: "با آتلیه رئوفی بیشتر آشنا شوید...",
|
||||||
body: [
|
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.
|
* meshkee API (see src/lib/api.ts); this is just the surrounding page copy.
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
export const portfoliosPage = {
|
export const projectsPage = {
|
||||||
hero: {
|
hero: {
|
||||||
caption: "OUR PORTFOLIO",
|
caption: "OUR PROJECTS",
|
||||||
title: "نمونه کارها",
|
title: "پروژهها",
|
||||||
breadcrumb: [
|
breadcrumb: [
|
||||||
{ label: "صفحه اصلی", href: "/" },
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
{ label: "نمونه کارها", href: "/portfolios" },
|
{ label: "پروژهها", href: "/projects" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
overview: {
|
overview: {
|
||||||
caption: "نمونه کارها",
|
caption: "پروژهها",
|
||||||
captionLatin: "PORTFOLIO",
|
captionLatin: "PROJECTS",
|
||||||
title: "پروژههایی که مسیر رئوفی را روایت میکنند",
|
title: "پروژههایی که مسیر رئوفی را روایت میکنند",
|
||||||
body: [
|
body: [
|
||||||
"هر پروژه، فصلی از مسیر گروه رئوفی است؛ از نخستین ساختوسازها تا برجهای امروز. در ادامه، نمونهکارهای منتشرشده را میبینید.",
|
"هر پروژه، فصلی از مسیر گروه رئوفی است؛ از نخستین ساختوسازها تا برجهای امروز. در ادامه، پروژههای منتشرشده را میبینید.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@ export const portfoliosPage = {
|
|||||||
} as const;
|
} 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
|
* 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.
|
* video-file link here and that project's video section turns on by itself.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Services } from "@/components/Services";
|
|||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { contact, site } from "@/data/site";
|
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
|
export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS
|
||||||
|
|
||||||
@@ -31,8 +31,8 @@ const organizationJsonLd = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const [portfolios, newsPosts] = await Promise.all([getRecentPortfolios(12), getRecentNews(3)]);
|
const [projects, newsPosts] = await Promise.all([getRecentProjects(12), getRecentNews(3)]);
|
||||||
const displayProjects = portfolios.map(toDisplayProject);
|
const displayProjects = projects.map(toDisplayProject);
|
||||||
const displayNews = newsPosts.map(toDisplayNewsItem);
|
const displayNews = newsPosts.map(toDisplayNewsItem);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,15 +5,17 @@
|
|||||||
* lives under `/tenants/{domain}/...` with no auth required.
|
* lives under `/tenants/{domain}/...` with no auth required.
|
||||||
*
|
*
|
||||||
* Note on categories: the tenant-level `/categories` endpoint exists but is
|
* Note on categories: the tenant-level `/categories` endpoint exists but is
|
||||||
* unrelated to portfolios in practice (it returns an empty product-category
|
* unrelated to the project records in practice (it returns an empty product-category
|
||||||
* list even though every portfolio item already carries its own categoryId/
|
* list even though every project item already carries its own categoryId/
|
||||||
* categoryName). Category tabs are therefore derived directly from the
|
* 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.
|
* to projects in their CMS, the filter picks them up with no code change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
|
|
||||||
|
import projectsFallback from "@/data/projects-fallback.json";
|
||||||
|
|
||||||
const API_BASE = "https://api.meshkee.com/api/v1";
|
const API_BASE = "https://api.meshkee.com/api/v1";
|
||||||
const SITE_DOMAIN = "raoofigroup.com";
|
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
|
* failure mode, not a hypothetical one. Failing fast here at least turns that
|
||||||
* into a clean error instead of a multi-minute hang.
|
* 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;
|
id: string;
|
||||||
businessId: string;
|
businessId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -59,30 +85,48 @@ export type ApiPortfolioItem = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PortfoliosResponse = {
|
type ProjectsResponse = {
|
||||||
items: ApiPortfolioItem[];
|
items: ApiProjectItem[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROJECTS_FALLBACK = projectsFallback as ProjectsResponse;
|
||||||
|
|
||||||
type FetchOptions = {
|
type FetchOptions = {
|
||||||
revalidate?: number;
|
revalidate?: number;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
async function apiFetch(path: string, options?: FetchOptions): Promise<Response> {
|
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 {
|
try {
|
||||||
return await fetch(`${API_BASE}${path}`, {
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
headers: { Accept: "application/json" },
|
timeoutId = setTimeout(() => {
|
||||||
next: {
|
controller.abort();
|
||||||
revalidate: options?.revalidate ?? REVALIDATE_LIST_SECONDS,
|
reject(new Error(`meshkee API ${path} exceeded ${REQUEST_TIMEOUT_MS}ms`));
|
||||||
tags: options?.tags,
|
}, REQUEST_TIMEOUT_MS);
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(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) {
|
} catch (cause) {
|
||||||
throw new Error(`meshkee API ${path} timed out or was unreachable`, { 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. */
|
/** 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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
mainTextHtml: "",
|
mainTextHtml: "",
|
||||||
@@ -150,31 +194,35 @@ async function fetchAllPages<T extends { items: unknown[]; total: number; pageSi
|
|||||||
return [first, ...rest].flatMap((page) => page.items);
|
return [first, ...rest].flatMap((page) => page.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All published portfolio items, every page merged, in the CMS's own order. */
|
/** All published projects, every page merged, in the CMS's own order. */
|
||||||
export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
|
export const getProjects = cache(async (): Promise<ApiProjectItem[]> => {
|
||||||
const items = await withApiFallback(
|
const items = await withApiFallback(
|
||||||
() => fetchAllPages<PortfoliosResponse>(
|
() => fetchAllPages<ProjectsResponse>(
|
||||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
|
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
|
||||||
{ tags: ["portfolios"] },
|
{ tags: ["portfolios"] },
|
||||||
),
|
),
|
||||||
[],
|
PROJECTS_FALLBACK.items,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (items as ApiPortfolioItem[])
|
return (items as ApiProjectItem[])
|
||||||
.filter((item) => item.status === "published")
|
.filter((item) => item.status === "published")
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
.map(stripListHeavyFields);
|
.map(stripListHeavyFields);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Recent published portfolios for home / about / services carousels. */
|
/** Recent published projects for home, about, and atelier carousels. */
|
||||||
export const getRecentPortfolios = cache(async (limit = 12): Promise<ApiPortfolioItem[]> => {
|
export const getRecentProjects = cache(async (limit = 12): Promise<ApiProjectItem[]> => {
|
||||||
const pageSize = Math.min(Math.max(limit, 1), 50);
|
const pageSize = Math.min(Math.max(limit, 1), 50);
|
||||||
const first = await withApiFallback(
|
const first = await withApiFallback(
|
||||||
() => apiGet<PortfoliosResponse>(
|
() => apiGet<ProjectsResponse>(
|
||||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`,
|
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`,
|
||||||
{ tags: ["portfolios"] },
|
{ tags: ["portfolios"] },
|
||||||
),
|
),
|
||||||
{ items: [], total: 0, page: 1, pageSize },
|
{
|
||||||
|
...PROJECTS_FALLBACK,
|
||||||
|
items: PROJECTS_FALLBACK.items.slice(0, pageSize),
|
||||||
|
pageSize,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return first.items
|
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. */
|
/** 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(
|
const items = await withApiFallback(
|
||||||
() => fetchAllPages<PortfoliosResponse>(
|
() => fetchAllPages<ProjectsResponse>(
|
||||||
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
|
`/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`,
|
||||||
{ tags: ["portfolios"] },
|
{ tags: ["portfolios"] },
|
||||||
),
|
),
|
||||||
[],
|
PROJECTS_FALLBACK.items,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (items as ApiPortfolioItem[])
|
return (items as ApiProjectItem[])
|
||||||
.filter((item) => item.status === "published")
|
.filter((item) => item.status === "published")
|
||||||
.map((item) => item.slug);
|
.map(toProjectRouteSlug);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Single portfolio by slug — avoids loading the full list on detail pages. */
|
/** Single project by slug — avoids loading the full list on detail pages. */
|
||||||
export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfolioItem | null> => {
|
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(
|
const data = await withApiFallback(
|
||||||
() => apiGetOptional<{ portfolio: ApiPortfolioItem }>(
|
() => apiGetOptional<{ portfolio: ApiProjectItem }>(
|
||||||
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
|
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
|
||||||
{
|
{
|
||||||
revalidate: REVALIDATE_DETAIL_SECONDS,
|
revalidate: REVALIDATE_DETAIL_SECONDS,
|
||||||
tags: ["portfolios", `portfolio:${slug}`],
|
tags: ["portfolios", `portfolio:${slug}`],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
null,
|
fallbackItem ? { portfolio: fallbackItem } : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const item = data?.portfolio;
|
const item = data?.portfolio;
|
||||||
@@ -220,11 +269,39 @@ export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfol
|
|||||||
return item;
|
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. */
|
if (Object.values(PROJECT_API_SLUG_BY_ROUTE).includes(routeSlug)) {
|
||||||
export function deriveCategories(items: ApiPortfolioItem[]): PortfolioCategory[] {
|
return getProjectBySlug(routeSlug);
|
||||||
const byId = new Map<string, PortfolioCategory>();
|
}
|
||||||
|
|
||||||
|
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) {
|
for (const item of items) {
|
||||||
const existing = byId.get(item.categoryId);
|
const existing = byId.get(item.categoryId);
|
||||||
@@ -245,6 +322,7 @@ export function deriveCategories(items: ApiPortfolioItem[]): PortfolioCategory[]
|
|||||||
|
|
||||||
export type DisplayProject = {
|
export type DisplayProject = {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
apiSlug: string;
|
||||||
name: string;
|
name: string;
|
||||||
nameLatin: string;
|
nameLatin: string;
|
||||||
type: 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";
|
"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
|
* an Aparat/iframe embed into the CMS's rich text editor ended up with the
|
||||||
* markup HTML-escaped (`<script>` instead of a real `<script>` tag), so
|
* markup HTML-escaped (`<script>` instead of a real `<script>` tag), so
|
||||||
* it renders as visible garbled text instead of a video. React's
|
* 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 {
|
return {
|
||||||
slug: item.slug,
|
slug: toProjectRouteSlug(item),
|
||||||
|
apiSlug: item.slug,
|
||||||
name: item.titleFa || item.title,
|
name: item.titleFa || item.title,
|
||||||
nameLatin: item.titleEn || item.titleFa || item.title,
|
nameLatin: item.titleEn || item.titleFa || item.title,
|
||||||
type: item.categoryName,
|
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
|
* 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".
|
* values "news" | "article" | "blog".
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Turns a plain video link into something embeddable.
|
* 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
|
* `projectVideos` in `src/data/site.ts`) rather than fetched — this just
|
||||||
* figures out how to play whatever link gets pasted there: an Aparat page
|
* figures out how to play whatever link gets pasted there: an Aparat page
|
||||||
* URL, a YouTube URL, or a direct video file.
|
* URL, a YouTube URL, or a direct video file.
|
||||||
|
|||||||