This commit is contained in:
amirhosein.ashourloo
2026-08-10 13:07:14 +03:30
parent 95c3c74276
commit c4e8a54623
15 changed files with 341 additions and 58 deletions
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
export const revalidate = 3600;
async function getBlogPosts() {
const posts = await getBlogs();
return posts.filter((p) => p.type !== "news");
}
export async function generateStaticParams() {
const posts = await getBlogPosts();
return posts.map((item) => ({ slug: item.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const posts = await getBlogPosts();
const item = posts.find((p) => p.slug === slug);
if (!item) return {};
return { title: item.titleFa || item.title, description: item.abstract || item.titleFa };
}
export default async function BlogDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const posts = await getBlogPosts();
const item = posts.find((p) => p.slug === slug);
if (!item) notFound();
return (
<>
<SiteHeader />
<main>
<NewsArticleDetail item={toDisplayNewsItem(item)} basePath="/blog" sectionLabel="بلاگ" />
</main>
<SiteFooter />
</>
);
}
+7 -2
View File
@@ -8,13 +8,16 @@ import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import heroImage from "@/assets/images/hero/hero-2.jpg";
import heroImageMobile from "@/assets/images/hero/hero-2-mobile.jpg";
import { blog, blogListingPage, newsAndBlogPage } from "@/data/site";
import { blogListingPage, newsAndBlogPage } from "@/data/site";
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
export const metadata: Metadata = {
title: "بلاگ",
description: "فهرست کامل مقالات منتشرشده‌ی گروه ساختمانی رئوفی.",
};
export const revalidate = 3600;
export default async function BlogListPage({
searchParams,
}: {
@@ -22,6 +25,8 @@ export default async function BlogListPage({
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const posts = await getBlogs();
const blog = posts.filter((p) => p.type !== "news").map(toDisplayNewsItem);
return (
<>
@@ -46,7 +51,7 @@ export default async function BlogListPage({
/>
</section>
<section className="bg-sand pb-16 lg:pb-24">
<section className="bg-sand py-16 lg:py-24">
<NewsListing
items={blog}
page={page}
+2 -2
View File
@@ -8,8 +8,8 @@
--color-paper: #fbfaf8;
--color-sand: #f2eee8;
--color-muted: #5c6873; /* brand body grey, carried over from the current site */
--color-gold: #b08d57;
--color-gold-soft: #cbab7d;
--color-gold: #f98223; /* Raoofi brand orange */
--color-gold-soft: #fbae70;
--color-line: #e2ddd5;
/* ---- type ---------------------------------------------------------- */
+9 -2
View File
@@ -10,14 +10,21 @@ import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { blog, news, newsAndBlogPage } from "@/data/site";
import { newsAndBlogPage } from "@/data/site";
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
export const metadata: Metadata = {
title: "مقالات و اخبار",
description: "آخرین اخبار، رویدادها و مقالات گروه ساختمانی رئوفی.",
};
export default function NewsAndBlogPage() {
export const revalidate = 3600;
export default async function NewsAndBlogPage() {
const posts = await getBlogs();
const news = posts.filter((p) => p.type === "news").map(toDisplayNewsItem);
const blog = posts.filter((p) => p.type !== "news").map(toDisplayNewsItem);
const hasNews = news.length > 0;
const hasBlog = blog.length > 0;
const hasNothing = !hasNews && !hasBlog;
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
export const revalidate = 3600;
export async function generateStaticParams() {
const posts = await getBlogs("news");
return posts.map((item) => ({ slug: item.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const posts = await getBlogs("news");
const item = posts.find((p) => p.slug === slug);
if (!item) return {};
return { title: item.titleFa || item.title, description: item.abstract || item.titleFa };
}
export default async function NewsDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const posts = await getBlogs("news");
const item = posts.find((p) => p.slug === slug);
if (!item) notFound();
return (
<>
<SiteHeader />
<main>
<NewsArticleDetail item={toDisplayNewsItem(item)} basePath="/news" sectionLabel="اخبار" />
</main>
<SiteFooter />
</>
);
}
+7 -2
View File
@@ -8,13 +8,16 @@ import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import heroImage from "@/assets/images/hero/hero-1.jpg";
import heroImageMobile from "@/assets/images/hero/hero-1-mobile.jpg";
import { news, newsAndBlogPage, newsListingPage } from "@/data/site";
import { newsAndBlogPage, newsListingPage } from "@/data/site";
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
export const metadata: Metadata = {
title: "اخبار",
description: "فهرست کامل اخبار و رویدادهای منتشرشده‌ی گروه ساختمانی رئوفی.",
};
export const revalidate = 3600;
export default async function NewsListPage({
searchParams,
}: {
@@ -22,6 +25,8 @@ export default async function NewsListPage({
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const posts = await getBlogs("news");
const news = posts.map(toDisplayNewsItem);
return (
<>
@@ -46,7 +51,7 @@ export default async function NewsListPage({
/>
</section>
<section className="bg-sand pb-16 lg:pb-24">
<section className="bg-sand py-16 lg:py-24">
<NewsListing
items={news}
page={page}
+4 -3
View File
@@ -8,7 +8,7 @@ import { Services } from "@/components/Services";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { contact, site } from "@/data/site";
import { getPortfolios, toDisplayProject } from "@/lib/api";
import { getBlogs, getPortfolios, toDisplayNewsItem, toDisplayProject } from "@/lib/api";
const organizationJsonLd = {
"@context": "https://schema.org",
@@ -29,8 +29,9 @@ const organizationJsonLd = {
};
export default async function HomePage() {
const portfolios = await getPortfolios();
const [portfolios, newsPosts] = await Promise.all([getPortfolios(), getBlogs("news")]);
const displayProjects = portfolios.map(toDisplayProject);
const displayNews = newsPosts.slice(0, 3).map(toDisplayNewsItem);
return (
<>
@@ -47,7 +48,7 @@ export default async function HomePage() {
<Marquee />
<Projects projects={displayProjects} />
<Services />
<News />
<News items={displayNews} />
<ContactCta />
</main>
+5 -4
View File
@@ -2,9 +2,11 @@ import Image from "next/image";
import Link from "next/link";
import { Reveal } from "@/components/Reveal";
import { news } from "@/data/site";
import type { DisplayNewsItem } from "@/lib/api";
export function News({ items }: { items: DisplayNewsItem[] }) {
if (items.length === 0) return null;
export function News() {
return (
<section id="news" className="bg-sand py-16 lg:py-24">
<div className="mx-auto max-w-[1400px] px-5 lg:px-10">
@@ -41,7 +43,7 @@ export function News() {
</div>
<ul className="mt-16 grid gap-8 md:grid-cols-3 lg:gap-10">
{news.map((item, i) => (
{items.map((item, i) => (
<Reveal as="li" key={item.slug} delay={i * 110}>
<Link href={`/news/${item.slug}`} className="group block h-full">
<article className="flex h-full flex-col bg-paper">
@@ -52,7 +54,6 @@ export function News() {
fill
quality={80}
sizes="(max-width: 768px) 100vw, 33vw"
placeholder="blur"
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" />
+102
View File
@@ -0,0 +1,102 @@
import { ProjectGalleryLightbox } from "@/components/portfolios/ProjectGalleryLightbox";
import { Reveal } from "@/components/Reveal";
import { PageCta } from "@/components/shared/PageCta";
import { PageHero } from "@/components/shared/PageHero";
import { newsAndBlogPage } from "@/data/site";
import type { DisplayNewsItem } from "@/lib/api";
/**
* Shared detail template for a single news or blog item — same rules as
* NewsListing keeps the two archives consistent, this keeps their detail
* pages consistent too, and mirrors the portfolio detail page's own
* jump-nav + gallery pattern since blog items carry the same gallery field.
* Body content is the item's real mainTextHtml from the API; nothing here
* is invented, and the gallery section only appears once a post actually
* has images to show.
*/
export function NewsArticleDetail({
item,
basePath,
sectionLabel,
}: {
item: DisplayNewsItem;
basePath: string;
sectionLabel: string;
}) {
const hasGallery = item.gallery.length > 0;
const navItems = [
{ id: "overview", label: "معرفی" },
...(hasGallery ? [{ id: "gallery", label: "گالری تصاویر" }] : []),
];
return (
<>
<PageHero
image={item.image}
caption={item.date}
title={item.title}
breadcrumb={[
{ label: "صفحه اصلی", href: "/" },
{ label: "مقالات و اخبار", href: "/news-and-blog" },
{ label: sectionLabel, href: basePath },
{ label: item.title, href: `${basePath}/${item.slug}` },
]}
/>
{navItems.length > 1 && (
<nav aria-label="بخش‌های صفحه" className="border-b border-line bg-paper">
<ul className="no-scrollbar mx-auto flex max-w-[1400px] items-center gap-8 overflow-x-auto px-6 lg:px-12">
{navItems.map((navItem) => (
<li key={navItem.id}>
<a
href={`#${navItem.id}`}
className="block scroll-mt-24 py-5 text-sm whitespace-nowrap text-muted transition-colors duration-300 hover:text-gold"
>
{navItem.label}
</a>
</li>
))}
</ul>
</nav>
)}
<section id="overview" className="scroll-mt-20 bg-paper py-16 lg:py-24">
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<div className="mx-auto mb-14 flex max-w-[738px] flex-col items-center gap-3 text-center">
<p className="kicker justify-center">{sectionLabel}</p>
<time dateTime={item.publishedAt} className="text-xs text-muted">
{item.date}
</time>
</div>
</Reveal>
{item.bodyHtml && (
<Reveal delay={90}>
<div className="cms-body" dangerouslySetInnerHTML={{ __html: item.bodyHtml }} />
</Reveal>
)}
</div>
</section>
{hasGallery && (
<section id="gallery" className="scroll-mt-20 bg-sand py-16 lg:py-24">
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
<Reveal>
<p className="kicker mb-14 justify-center">گالری تصاویر</p>
</Reveal>
<ProjectGalleryLightbox images={item.gallery} alt={item.title} />
</div>
</section>
)}
<PageCta
caption={newsAndBlogPage.cta.caption}
title={newsAndBlogPage.cta.title}
action={newsAndBlogPage.cta.action}
/>
</>
);
}
+8 -3
View File
@@ -5,10 +5,16 @@ import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { Reveal } from "@/components/Reveal";
import type { NewsItem } from "@/data/site";
import type { DisplayNewsItem } from "@/lib/api";
/** Horizontal snap-scroll rail of news/blog cards, matching the site's other carousels. */
export function NewsCarousel({ items, basePath }: { items: NewsItem[]; basePath: string }) {
export function NewsCarousel({
items,
basePath,
}: {
items: DisplayNewsItem[];
basePath: string;
}) {
const trackRef = useRef<HTMLUListElement>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
@@ -92,7 +98,6 @@ export function NewsCarousel({ items, basePath }: { items: NewsItem[]; basePath:
fill
quality={80}
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 52vw, 26rem"
placeholder="blur"
loading={i < 2 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.06]"
/>
+2 -3
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { Reveal } from "@/components/Reveal";
import { Pagination } from "@/components/shared/Pagination";
import type { NewsItem } from "@/data/site";
import type { DisplayNewsItem } from "@/lib/api";
const PAGE_SIZE = 9;
@@ -18,7 +18,7 @@ export function NewsListing({
basePath,
emptyMessage,
}: {
items: NewsItem[];
items: DisplayNewsItem[];
page: number;
basePath: string;
emptyMessage: string;
@@ -44,7 +44,6 @@ export function NewsListing({
fill
quality={80}
sizes="(max-width: 768px) 100vw, 33vw"
placeholder="blur"
loading={i < 3 ? "eager" : "lazy"}
className="object-cover transition-transform duration-[1100ms] ease-out-soft group-hover:scale-[1.07]"
/>
@@ -51,7 +51,7 @@ export function PortfolioBrowser({
type="button"
onClick={() => setActiveId(ALL_ID)}
aria-current={activeId === ALL_ID}
className={`relative whitespace-nowrap pb-4 text-sm transition-colors duration-300 ${
className={`relative whitespace-nowrap pb-4 text-sm font-bold transition-colors duration-300 ${
activeId === ALL_ID ? "text-ink" : "text-muted hover:text-ink"
}`}
>
@@ -70,7 +70,7 @@ export function PortfolioBrowser({
type="button"
onClick={() => setActiveId(cat.id)}
aria-current={activeId === cat.id}
className={`relative whitespace-nowrap pb-4 text-sm transition-colors duration-300 ${
className={`relative whitespace-nowrap pb-4 text-sm font-bold transition-colors duration-300 ${
activeId === cat.id ? "text-ink" : "text-muted hover:text-ink"
}`}
>
+1 -1
View File
@@ -49,7 +49,7 @@ export function Editorial({
{title && (
<Reveal delay={90}>
<h2
className={`max-w-[927px] text-[1.9rem] leading-[1.45] font-light sm:text-4xl lg:text-5xl lg:leading-[1.3] ${heading}`}
className={`max-w-[927px] text-[1.9rem] leading-[1.45] font-normal sm:text-4xl lg:text-5xl lg:leading-[1.3] ${heading}`}
>
{title}
</h2>
-34
View File
@@ -14,10 +14,6 @@ import pania from "@/assets/images/projects/pania.jpg";
import ghasr3 from "@/assets/images/projects/ghasr-3.jpg";
import goldenGhasr from "@/assets/images/projects/golden-ghasr.jpg";
import yearEnd from "@/assets/images/news/year-end-ceremony.jpg";
import elysiumOpening from "@/assets/images/news/elysium-opening.jpg";
import hayatFoundation from "@/assets/images/news/hayat-foundation.jpg";
export const site = {
name: "گروه ساختمانی رئوفی",
tagline: "سازنده رویاهای شما",
@@ -385,33 +381,3 @@ export const blogListingPage = {
emptyMessage: "هنوز مقاله‌ای منتشر نشده است.",
} as const;
export type NewsItem = {
slug: string;
title: string;
date: string;
image: StaticImageData;
};
export const news: NewsItem[] = [
{
slug: "year-end-ceremony",
title: "مراسم پایان سال گروه رئوفی",
date: "شنبه ۲۲ شهریور ۱۴۰۴",
image: yearEnd,
},
{
slug: "elysium-opening",
title: "مراسم افتتاحیه برج الیزیوم",
date: "سه‌شنبه ۱۹ فروردین ۱۴۰۴",
image: elysiumOpening,
},
{
slug: "hayat-foundation",
title: "بتن ریزی فوندانسیون پروژه حیات",
date: "شنبه ۱۳ بهمن ۱۴۰۳",
image: hayatFoundation,
},
];
/** No blog posts published anywhere yet — genuinely empty, not left out. */
export const blog: NewsItem[] = [];
+93
View File
@@ -165,3 +165,96 @@ export function toDisplayProject(item: ApiPortfolioItem): DisplayProject {
publishedAt: item.publishedAt,
};
}
/* ---------------------------------------------------------------------------
* Blogs / news — same API family as portfolios (same base URL, same list
* envelope, a sibling {slug} + {id}/comments pair), so the item shape below
* mirrors ApiPortfolioItem. `/blogs` additionally takes a `type` filter with
* values "news" | "article" | "blog".
* ------------------------------------------------------------------------- */
export type BlogType = "news" | "article" | "blog";
export type ApiBlogItem = {
id: string;
businessId: string;
title: string;
titleFa: string;
titleEn: string;
slug: string;
abstract: string;
mainTextHtml: string;
status: string;
type: BlogType;
categoryId: string;
categoryName: string;
tags: string[];
titleImageUrl: string | null;
featuredMediaId: string | null;
gallery: { mediaId: string; url: string }[];
galleryMediaIds: string[];
sortOrder: number;
publishedAt: string;
createdAt: string;
updatedAt: string;
};
type BlogsResponse = {
items: ApiBlogItem[];
total: number;
page: number;
pageSize: number;
};
/** All published blog/news items, every page merged. Optionally filtered by `type`. */
export async function getBlogs(type?: BlogType): Promise<ApiBlogItem[]> {
const query = type ? `?type=${type}` : "";
const first = await apiGet<BlogsResponse>(`/tenants/${SITE_DOMAIN}/blogs${query}`);
const pageCount = Math.ceil(first.total / first.pageSize);
const rest = await Promise.all(
Array.from({ length: Math.max(0, pageCount - 1) }, (_, i) =>
apiGet<BlogsResponse>(
`/tenants/${SITE_DOMAIN}/blogs${query}${query ? "&" : "?"}page=${i + 2}`,
),
),
);
return [first, ...rest]
.flatMap((page) => page.items)
.filter((item) => item.status === "published")
.sort((a, b) => a.sortOrder - b.sortOrder);
}
export async function getBlogBySlug(slug: string): Promise<ApiBlogItem | null> {
const items = await getBlogs();
return items.find((item) => item.slug === slug) ?? null;
}
export type DisplayNewsItem = {
slug: string;
title: string;
date: string;
image: string;
gallery: string[];
bodyHtml: string;
publishedAt: string;
};
const dateFormatter = new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
});
export function toDisplayNewsItem(item: ApiBlogItem): DisplayNewsItem {
return {
slug: item.slug,
title: item.titleFa || item.title,
date: dateFormatter.format(new Date(item.publishedAt)),
image: item.titleImageUrl ?? FALLBACK_IMAGE,
gallery: item.gallery.map((g) => g.url),
bodyHtml: stripBrokenEmbedArtifacts(item.mainTextHtml),
publishedAt: item.publishedAt,
};
}