diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..e845324 --- /dev/null +++ b/src/app/blog/[slug]/page.tsx @@ -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 { + 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 ( + <> + +
+ +
+ + + ); +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 5401852..67b11e7 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -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({ /> -
+
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; diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx new file mode 100644 index 0000000..966dd70 --- /dev/null +++ b/src/app/news/[slug]/page.tsx @@ -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 { + 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 ( + <> + +
+ +
+ + + ); +} diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx index d52be46..7659f0c 100644 --- a/src/app/news/page.tsx +++ b/src/app/news/page.tsx @@ -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({ />
-
+
@@ -47,7 +48,7 @@ export default async function HomePage() { - + diff --git a/src/components/News.tsx b/src/components/News.tsx index 50f45b5..04971af 100644 --- a/src/components/News.tsx +++ b/src/components/News.tsx @@ -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 (
@@ -41,7 +43,7 @@ export function News() {
    - {news.map((item, i) => ( + {items.map((item, i) => (
    @@ -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]" />
    diff --git a/src/components/news/NewsArticleDetail.tsx b/src/components/news/NewsArticleDetail.tsx new file mode 100644 index 0000000..989c79c --- /dev/null +++ b/src/components/news/NewsArticleDetail.tsx @@ -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 ( + <> + + + {navItems.length > 1 && ( + + )} + +
    +
    + +
    +

    {sectionLabel}

    + +
    +
    + + {item.bodyHtml && ( + +
    + + )} +
    +
    + + {hasGallery && ( + + )} + + + + ); +} diff --git a/src/components/news/NewsCarousel.tsx b/src/components/news/NewsCarousel.tsx index 177e097..bc5689f 100644 --- a/src/components/news/NewsCarousel.tsx +++ b/src/components/news/NewsCarousel.tsx @@ -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(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]" /> diff --git a/src/components/news/NewsListing.tsx b/src/components/news/NewsListing.tsx index 685e25b..fbd8553 100644 --- a/src/components/news/NewsListing.tsx +++ b/src/components/news/NewsListing.tsx @@ -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]" /> diff --git a/src/components/portfolios/PortfolioBrowser.tsx b/src/components/portfolios/PortfolioBrowser.tsx index 9aa3655..230c0f7 100644 --- a/src/components/portfolios/PortfolioBrowser.tsx +++ b/src/components/portfolios/PortfolioBrowser.tsx @@ -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" }`} > diff --git a/src/components/shared/Editorial.tsx b/src/components/shared/Editorial.tsx index 2e206b7..f7b2dd9 100644 --- a/src/components/shared/Editorial.tsx +++ b/src/components/shared/Editorial.tsx @@ -49,7 +49,7 @@ export function Editorial({ {title && (

    {title}

    diff --git a/src/data/site.ts b/src/data/site.ts index 30eab72..1a96884 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -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[] = []; diff --git a/src/lib/api.ts b/src/lib/api.ts index 8bc5af9..be32eb9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { + const query = type ? `?type=${type}` : ""; + const first = await apiGet(`/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( + `/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 { + 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, + }; +}