diff --git a/next.config.ts b/next.config.ts index 26a6589..ed456f4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,10 +5,13 @@ const nextConfig: NextConfig = { formats: ["image/avif", "image/webp"], // Every quality value used across the page. Required from Next.js 16 on. qualities: [70, 72, 75, 80, 82, 88, 90], - // Portfolio media served live from the meshkee API. + // Portfolio media served live from the meshkee API. The storage host has + // already changed once mid-project (parminstorage.ir -> parspack.net) — + // keep both around since either could be what a given item still points to. remotePatterns: [ { protocol: "https", hostname: "meshkee-storage.sas.amin.parminstorage.ir" }, { protocol: "https", hostname: "cdn.meshkee.com" }, + { protocol: "https", hostname: "c804387.parspack.net" }, ], }, }; diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..5401852 --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from "next"; + +import { NewsListing } from "@/components/news/NewsListing"; +import { Editorial } from "@/components/shared/Editorial"; +import { PageCta } from "@/components/shared/PageCta"; +import { PageHero } from "@/components/shared/PageHero"; +import { SiteFooter } from "@/components/SiteFooter"; +import { SiteHeader } from "@/components/SiteHeader"; +import heroImage from "@/assets/images/hero/hero-2.jpg"; +import heroImageMobile from "@/assets/images/hero/hero-2-mobile.jpg"; +import { blog, blogListingPage, newsAndBlogPage } from "@/data/site"; + +export const metadata: Metadata = { + title: "بلاگ", + description: "فهرست کامل مقالات منتشرشده‌ی گروه ساختمانی رئوفی.", +}; + +export default async function BlogListPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const { page: pageParam } = await searchParams; + const page = Math.max(1, Number(pageParam) || 1); + + return ( + <> + + +
+ + +
+ +
+ +
+ +
+ + +
+ + + + ); +} diff --git a/src/app/news-and-blog/page.tsx b/src/app/news-and-blog/page.tsx new file mode 100644 index 0000000..c8edad2 --- /dev/null +++ b/src/app/news-and-blog/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import heroImage from "@/assets/images/hero/hero-1.jpg"; +import heroImageMobile from "@/assets/images/hero/hero-1-mobile.jpg"; +import { Reveal } from "@/components/Reveal"; +import { NewsCarousel } from "@/components/news/NewsCarousel"; +import { Editorial } from "@/components/shared/Editorial"; +import { PageCta } from "@/components/shared/PageCta"; +import { PageHero } from "@/components/shared/PageHero"; +import { SiteFooter } from "@/components/SiteFooter"; +import { SiteHeader } from "@/components/SiteHeader"; +import { blog, news, newsAndBlogPage } from "@/data/site"; + +export const metadata: Metadata = { + title: "مقالات و اخبار", + description: "آخرین اخبار، رویدادها و مقالات گروه ساختمانی رئوفی.", +}; + +export default function NewsAndBlogPage() { + const hasNews = news.length > 0; + const hasBlog = blog.length > 0; + const hasNothing = !hasNews && !hasBlog; + + return ( + <> + + +
+ + +
+ +
+ + {hasNothing && ( +
+

{newsAndBlogPage.emptyMessage}

+
+ )} + + {hasNews && ( +
+
+ +

+ {newsAndBlogPage.news.caption} + {newsAndBlogPage.news.captionLatin} +

+
+
+ + + +
+ + {newsAndBlogPage.news.action} + +
+
+ )} + + {hasBlog && ( +
+
+ +

+ {newsAndBlogPage.blog.caption} + {newsAndBlogPage.blog.captionLatin} +

+
+
+ + + +
+ + {newsAndBlogPage.blog.action} + +
+
+ )} + + +
+ + + + ); +} diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx new file mode 100644 index 0000000..d52be46 --- /dev/null +++ b/src/app/news/page.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from "next"; + +import { NewsListing } from "@/components/news/NewsListing"; +import { Editorial } from "@/components/shared/Editorial"; +import { PageCta } from "@/components/shared/PageCta"; +import { PageHero } from "@/components/shared/PageHero"; +import { SiteFooter } from "@/components/SiteFooter"; +import { SiteHeader } from "@/components/SiteHeader"; +import heroImage from "@/assets/images/hero/hero-1.jpg"; +import heroImageMobile from "@/assets/images/hero/hero-1-mobile.jpg"; +import { news, newsAndBlogPage, newsListingPage } from "@/data/site"; + +export const metadata: Metadata = { + title: "اخبار", + description: "فهرست کامل اخبار و رویدادهای منتشرشده‌ی گروه ساختمانی رئوفی.", +}; + +export default async function NewsListPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const { page: pageParam } = await searchParams; + const page = Math.max(1, Number(pageParam) || 1); + + return ( + <> + + +
+ + +
+ +
+ +
+ +
+ + +
+ + + + ); +} diff --git a/src/components/News.tsx b/src/components/News.tsx index 4056f2d..50f45b5 100644 --- a/src/components/News.tsx +++ b/src/components/News.tsx @@ -24,7 +24,7 @@ export function News() { آرشیو مقالات و اخبار diff --git a/src/components/news/NewsCarousel.tsx b/src/components/news/NewsCarousel.tsx new file mode 100644 index 0000000..177e097 --- /dev/null +++ b/src/components/news/NewsCarousel.tsx @@ -0,0 +1,130 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Reveal } from "@/components/Reveal"; +import type { NewsItem } from "@/data/site"; + +/** Horizontal snap-scroll rail of news/blog cards, matching the site's other carousels. */ +export function NewsCarousel({ items, basePath }: { items: NewsItem[]; basePath: string }) { + const trackRef = useRef(null); + const [atStart, setAtStart] = useState(true); + const [atEnd, setAtEnd] = useState(false); + + const syncEdges = useCallback(() => { + const el = trackRef.current; + if (!el) return; + const travelled = Math.abs(el.scrollLeft); + const max = el.scrollWidth - el.clientWidth; + setAtStart(travelled < 8); + setAtEnd(travelled >= max - 8); + }, []); + + useEffect(() => { + syncEdges(); + const el = trackRef.current; + if (!el) return; + el.addEventListener("scroll", syncEdges, { passive: true }); + window.addEventListener("resize", syncEdges); + return () => { + el.removeEventListener("scroll", syncEdges); + window.removeEventListener("resize", syncEdges); + }; + }, [syncEdges]); + + const step = (direction: "next" | "prev") => { + const el = trackRef.current; + if (!el) return; + const card = el.querySelector("li"); + const amount = card ? card.getBoundingClientRect().width + 24 : el.clientWidth * 0.8; + el.scrollBy({ left: direction === "next" ? -amount : amount, behavior: "smooth" }); + }; + + const showArrows = items.length > 3; + + return ( +
+ {showArrows && ( +
+ + +
+ )} + +
    + {items.map((item, i) => ( +
  • + + +
    +
    + {item.title} +
    +
    + +
    + + +

    + {item.title} +

    + + + ادامه مطلب + + +
    +
    + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/news/NewsListing.tsx b/src/components/news/NewsListing.tsx new file mode 100644 index 0000000..685e25b --- /dev/null +++ b/src/components/news/NewsListing.tsx @@ -0,0 +1,88 @@ +import Image from "next/image"; +import Link from "next/link"; + +import { Reveal } from "@/components/Reveal"; +import { Pagination } from "@/components/shared/Pagination"; +import type { NewsItem } from "@/data/site"; + +const PAGE_SIZE = 9; + +/** + * Grid + pagination for a full news or blog archive. Used by both /news and + * /blog so the two keep identical rules — only the item list, base path and + * empty-state copy differ between them. + */ +export function NewsListing({ + items, + page, + basePath, + emptyMessage, +}: { + items: NewsItem[]; + page: number; + basePath: string; + emptyMessage: string; +}) { + const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE)); + const currentPage = Math.min(totalPages, Math.max(1, page)); + const pageItems = items.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + return ( +
+ {pageItems.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
    + {pageItems.map((item, i) => ( + + +
    +
    + {item.title} +
    +
    + +
    + + +

    + {item.title} +

    + + + ادامه مطلب + + +
    +
    + +
    + ))} +
+ )} + + `${basePath}?page=${p}`} + /> +
+ ); +} diff --git a/src/components/portfolios/PortfolioBrowser.tsx b/src/components/portfolios/PortfolioBrowser.tsx index 6fc9a2f..9aa3655 100644 --- a/src/components/portfolios/PortfolioBrowser.tsx +++ b/src/components/portfolios/PortfolioBrowser.tsx @@ -2,12 +2,14 @@ import Image from "next/image"; import Link from "next/link"; -import { useMemo, useState } from "react"; +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. @@ -25,6 +27,7 @@ export function PortfolioBrowser({ categories: PortfolioCategory[]; }) { const [activeId, setActiveId] = useState(ALL_ID); + const [page, setPage] = useState(1); const showTabs = categories.length > 1; const filtered = useMemo( @@ -32,6 +35,12 @@ export function PortfolioBrowser({ [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 (
{showTabs && ( @@ -84,8 +93,8 @@ export function PortfolioBrowser({ پروژه‌ای در این دسته یافت نشد.

) : ( -
    - {filtered.map((project, i) => ( +
      + {pageItems.map((project, i) => (
      @@ -126,6 +135,10 @@ export function PortfolioBrowser({ ))}
    )} + +
    + +
); } diff --git a/src/components/portfolios/ProjectGalleryLightbox.tsx b/src/components/portfolios/ProjectGalleryLightbox.tsx index 05e4f41..a39ed55 100644 --- a/src/components/portfolios/ProjectGalleryLightbox.tsx +++ b/src/components/portfolios/ProjectGalleryLightbox.tsx @@ -115,7 +115,7 @@ export function ProjectGalleryLightbox({ images, alt }: { images: string[]; alt: showPrev(); }} aria-label="تصویر قبلی" - className="absolute end-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:end-6" + className="absolute start-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:start-6" > @@ -126,7 +126,7 @@ export function ProjectGalleryLightbox({ images, alt }: { images: string[]; alt: showNext(); }} aria-label="تصویر بعدی" - className="absolute start-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:start-6" + className="absolute end-3 top-1/2 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:end-6" > diff --git a/src/components/shared/Pagination.tsx b/src/components/shared/Pagination.tsx new file mode 100644 index 0000000..6889a0a --- /dev/null +++ b/src/components/shared/Pagination.tsx @@ -0,0 +1,94 @@ +import Link from "next/link"; + +function ChevronIcon({ direction, className }: { direction: "next" | "prev"; className?: string }) { + const d = direction === "next" ? "M19 12H5m0 0 6-6m-6 6 6 6" : "M5 12h14m0 0-6-6m6 6-6 6"; + return ( + + ); +} + +type PaginationProps = { + page: number; + totalPages: number; +} & ( + | { hrefFor: (page: number) => string; onPageChange?: never } + | { onPageChange: (page: number) => void; hrefFor?: never } +); + +/** + * Numbered pagination, RTL-aware. Works two ways: give it `hrefFor` for a + * plain server-rendered listing (real links, works without JS), or + * `onPageChange` for a page that already manages its own list client-side + * (e.g. alongside a category filter). + * + * Renders nothing when everything already fits on one page — there's no + * reason to show page controls for content that doesn't need paging yet. + */ +export function Pagination({ page, totalPages, hrefFor, onPageChange }: PaginationProps) { + if (totalPages <= 1) return null; + + const prevPage = Math.max(1, page - 1); + const nextPage = Math.min(totalPages, page + 1); + const pages = Array.from({ length: totalPages }, (_, i) => i + 1); + + const controlClass = (current: boolean, disabled = false) => + `flex h-11 min-w-11 items-center justify-center rounded-full px-3 text-sm transition-colors duration-300 ${ + current + ? "bg-gold text-white" + : disabled + ? "pointer-events-none text-muted/30" + : "text-ink hover:bg-sand" + }`; + + const renderItem = (target: number, content: React.ReactNode, ariaLabel: string, current = false) => { + if (hrefFor) { + return ( + + {content} + + ); + } + return ( + + ); + }; + + return ( + + ); +} diff --git a/src/data/site.ts b/src/data/site.ts index a1c17a1..30eab72 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -31,7 +31,7 @@ export const nav = [ { label: "صفحه اصلی", href: "/" }, { label: "خدمات ما", href: "/our-services" }, { label: "پروژه ها", href: "/portfolios" }, - { label: "مقالات و اخبار", href: "/news" }, + { label: "مقالات و اخبار", href: "/news-and-blog" }, { label: "درباره ما", href: "/aboutus" }, { label: "تماس با ما", href: "/contactus" }, ] as const; @@ -294,6 +294,97 @@ export const projectVideos: Record = { "item-old-589": "https://www.aparat.com/v/dnem1b3", }; +/* --------------------------------------------------------------------------- + * News & blog + * + * The meshkee API has a real `/blogs` endpoint with a `type` filter + * (news/article/blog), but it currently returns zero items and the docs + * don't spell out the item's field names — nothing to verify a live + * integration against yet. `news` below is real, published content (scraped + * from the live site, not invented); `blog` is genuinely empty because the + * client has no blog content anywhere yet, not because it was left out. + * Wire both up to live `/blogs?type=...` fetches once there's real content + * to check the field shape against. + * ------------------------------------------------------------------------- */ + +export const newsAndBlogPage = { + hero: { + caption: "NEWS & MEDIA", + title: "مقالات و اخبار", + breadcrumb: [ + { label: "صفحه اصلی", href: "/" }, + { label: "مقالات و اخبار", href: "/news-and-blog" }, + ], + }, + + overview: { + caption: "مقالات و اخبار", + captionLatin: "NEWS & MEDIA", + title: "آخرین رویدادهای گروه رئوفی", + body: [ + "از افتتاحیه‌ی پروژه‌ها تا مراسم و بروزرسانی‌های ساخت‌وساز؛ در ادامه تازه‌ترین اخبار و مقالات منتشرشده را می‌بینید.", + ], + }, + + news: { + caption: "اخبار", + captionLatin: "NEWS", + action: "مشاهده همه اخبار", + }, + + blog: { + caption: "بلاگ", + captionLatin: "BLOG", + action: "مشاهده همه مقالات", + }, + + emptyMessage: "هیچ مقاله یا خبری ثبت نشده است.", + + cta: { + caption: "STAY UPDATED", + title: "از پروژه‌های تازه‌ی رئوفی باخبر شوید", + action: "تماس با ما", + }, +} as const; + +export const newsListingPage = { + hero: { + caption: "NEWS", + title: "اخبار", + breadcrumb: [ + { label: "صفحه اصلی", href: "/" }, + { label: "مقالات و اخبار", href: "/news-and-blog" }, + { label: "اخبار", href: "/news" }, + ], + }, + overview: { + caption: "اخبار", + captionLatin: "NEWS", + title: "آرشیو اخبار", + body: ["فهرست کامل اخبار و رویدادهای منتشرشده‌ی گروه رئوفی."], + }, + emptyMessage: "هنوز خبری منتشر نشده است.", +} as const; + +export const blogListingPage = { + hero: { + caption: "BLOG", + title: "بلاگ", + breadcrumb: [ + { label: "صفحه اصلی", href: "/" }, + { label: "مقالات و اخبار", href: "/news-and-blog" }, + { label: "بلاگ", href: "/blog" }, + ], + }, + overview: { + caption: "بلاگ", + captionLatin: "BLOG", + title: "آرشیو مقالات", + body: ["فهرست کامل مقالات منتشرشده‌ی گروه رئوفی."], + }, + emptyMessage: "هنوز مقاله‌ای منتشر نشده است.", +} as const; + export type NewsItem = { slug: string; title: string; @@ -321,3 +412,6 @@ export const news: NewsItem[] = [ image: hayatFoundation, }, ]; + +/** No blog posts published anywhere yet — genuinely empty, not left out. */ +export const blog: NewsItem[] = [];