mirror of
https://git.meshkee.com/Meshkee-Websites/raoufi-group.git
synced 2026-08-11 20:29:33 +04:30
new create
This commit is contained in:
+4
-1
@@ -5,10 +5,13 @@ 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.
|
// 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: [
|
remotePatterns: [
|
||||||
{ protocol: "https", hostname: "meshkee-storage.sas.amin.parminstorage.ir" },
|
{ protocol: "https", hostname: "meshkee-storage.sas.amin.parminstorage.ir" },
|
||||||
{ protocol: "https", hostname: "cdn.meshkee.com" },
|
{ protocol: "https", hostname: "cdn.meshkee.com" },
|
||||||
|
{ protocol: "https", hostname: "c804387.parspack.net" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<SiteHeader />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<PageHero
|
||||||
|
image={heroImage}
|
||||||
|
imageMobile={heroImageMobile}
|
||||||
|
caption={blogListingPage.hero.caption}
|
||||||
|
title={blogListingPage.hero.title}
|
||||||
|
breadcrumb={blogListingPage.hero.breadcrumb}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="bg-paper">
|
||||||
|
<Editorial
|
||||||
|
caption={blogListingPage.overview.caption}
|
||||||
|
captionLatin={blogListingPage.overview.captionLatin}
|
||||||
|
title={blogListingPage.overview.title}
|
||||||
|
body={blogListingPage.overview.body}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-sand pb-16 lg:pb-24">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<SiteHeader />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<PageHero
|
||||||
|
image={heroImage}
|
||||||
|
imageMobile={heroImageMobile}
|
||||||
|
caption={newsAndBlogPage.hero.caption}
|
||||||
|
title={newsAndBlogPage.hero.title}
|
||||||
|
breadcrumb={newsAndBlogPage.hero.breadcrumb}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="bg-paper">
|
||||||
|
<Editorial
|
||||||
|
caption={newsAndBlogPage.overview.caption}
|
||||||
|
captionLatin={newsAndBlogPage.overview.captionLatin}
|
||||||
|
title={newsAndBlogPage.overview.title}
|
||||||
|
body={newsAndBlogPage.overview.body}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{hasNothing && (
|
||||||
|
<section className="bg-sand py-16 lg:py-24">
|
||||||
|
<p className="text-center text-muted">{newsAndBlogPage.emptyMessage}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasNews && (
|
||||||
|
<section className="bg-sand py-16 lg:py-24">
|
||||||
|
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
|
||||||
|
<Reveal>
|
||||||
|
<p className="kicker mb-10 justify-center">
|
||||||
|
{newsAndBlogPage.news.caption}
|
||||||
|
<span className="latin">{newsAndBlogPage.news.captionLatin}</span>
|
||||||
|
</p>
|
||||||
|
</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 rounded-full bg-gold px-9 py-3.5 text-white transition-colors duration-300 hover:bg-ink"
|
||||||
|
>
|
||||||
|
{newsAndBlogPage.news.action}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasBlog && (
|
||||||
|
<section className={`py-16 lg:py-24 ${hasNews ? "bg-paper" : "bg-sand"}`}>
|
||||||
|
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
|
||||||
|
<Reveal>
|
||||||
|
<p className="kicker mb-10 justify-center">
|
||||||
|
{newsAndBlogPage.blog.caption}
|
||||||
|
<span className="latin">{newsAndBlogPage.blog.captionLatin}</span>
|
||||||
|
</p>
|
||||||
|
</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 rounded-full bg-gold px-9 py-3.5 text-white transition-colors duration-300 hover:bg-ink"
|
||||||
|
>
|
||||||
|
{newsAndBlogPage.blog.action}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageCta
|
||||||
|
caption={newsAndBlogPage.cta.caption}
|
||||||
|
title={newsAndBlogPage.cta.title}
|
||||||
|
action={newsAndBlogPage.cta.action}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<SiteHeader />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<PageHero
|
||||||
|
image={heroImage}
|
||||||
|
imageMobile={heroImageMobile}
|
||||||
|
caption={newsListingPage.hero.caption}
|
||||||
|
title={newsListingPage.hero.title}
|
||||||
|
breadcrumb={newsListingPage.hero.breadcrumb}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="bg-paper">
|
||||||
|
<Editorial
|
||||||
|
caption={newsListingPage.overview.caption}
|
||||||
|
captionLatin={newsListingPage.overview.captionLatin}
|
||||||
|
title={newsListingPage.overview.title}
|
||||||
|
body={newsListingPage.overview.body}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-sand pb-16 lg:pb-24">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export function News() {
|
|||||||
|
|
||||||
<Reveal delay={140}>
|
<Reveal delay={140}>
|
||||||
<Link
|
<Link
|
||||||
href="/news"
|
href="/news-and-blog"
|
||||||
className="group flex items-center gap-3 border-b border-ink/20 pb-2 text-ink transition-colors duration-300 hover:border-gold hover:text-gold"
|
className="group flex items-center gap-3 border-b border-ink/20 pb-2 text-ink transition-colors duration-300 hover:border-gold hover:text-gold"
|
||||||
>
|
>
|
||||||
آرشیو مقالات و اخبار
|
آرشیو مقالات و اخبار
|
||||||
|
|||||||
@@ -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<HTMLUListElement>(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 (
|
||||||
|
<div>
|
||||||
|
{showArrows && (
|
||||||
|
<div className="mx-auto mb-6 flex max-w-[1400px] justify-end gap-2 px-6 lg:px-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step("prev")}
|
||||||
|
disabled={atStart}
|
||||||
|
aria-label="اخبار قبلی"
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-full border border-line text-ink transition-colors duration-300 hover:border-gold hover:bg-gold hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
|
||||||
|
<path d="M5 12h14m0 0-6-6m6 6-6 6" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step("next")}
|
||||||
|
disabled={atEnd}
|
||||||
|
aria-label="اخبار بعدی"
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-full border border-line text-ink transition-colors duration-300 hover:border-gold hover:bg-gold hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<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.6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul
|
||||||
|
ref={trackRef}
|
||||||
|
className="no-scrollbar flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth px-6 pb-2 lg:gap-8 lg:px-12"
|
||||||
|
>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={item.slug}
|
||||||
|
className="w-[82vw] shrink-0 snap-start sm:w-[52vw] lg:w-[34vw] xl:w-[26rem]"
|
||||||
|
>
|
||||||
|
<Reveal delay={(i % 4) * 80}>
|
||||||
|
<Link href={`${basePath}/${item.slug}`} className="group block h-full">
|
||||||
|
<article className="flex h-full flex-col bg-paper">
|
||||||
|
<div className="relative aspect-[16/11] overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
<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 p-7 lg:p-8">
|
||||||
|
<time className="flex items-center gap-2.5 text-[0.8rem] text-muted">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 text-gold">
|
||||||
|
<rect x="3.5" y="5" width="17" height="15.5" rx="2" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
<path d="M3.5 10h17M8 3.5V6M16 3.5V6" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
</svg>
|
||||||
|
{item.date}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<h3 className="mt-4 text-[1.1rem] leading-[1.75] font-bold text-ink transition-colors duration-300 group-hover:text-gold">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<span className="mt-auto flex items-center gap-2 pt-7 text-sm text-muted transition-colors duration-300 group-hover:text-gold">
|
||||||
|
ادامه مطلب
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1">
|
||||||
|
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
</Reveal>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="mx-auto max-w-[1400px] px-6 lg:px-12">
|
||||||
|
{pageItems.length === 0 ? (
|
||||||
|
<p className="py-16 text-center text-muted">{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-8 md:grid-cols-3 lg:gap-10">
|
||||||
|
{pageItems.map((item, i) => (
|
||||||
|
<Reveal as="li" key={item.slug} delay={(i % 6) * 70}>
|
||||||
|
<Link href={`${basePath}/${item.slug}`} className="group block h-full">
|
||||||
|
<article className="flex h-full flex-col bg-paper">
|
||||||
|
<div className="relative aspect-[16/11] overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
<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 p-7 lg:p-8">
|
||||||
|
<time className="flex items-center gap-2.5 text-[0.8rem] text-muted">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 text-gold">
|
||||||
|
<rect x="3.5" y="5" width="17" height="15.5" rx="2" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
<path d="M3.5 10h17M8 3.5V6M16 3.5V6" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
</svg>
|
||||||
|
{item.date}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<h3 className="mt-4 text-[1.22rem] leading-[1.75] font-bold text-ink transition-colors duration-300 group-hover:text-gold">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<span className="mt-auto flex items-center gap-2 pt-7 text-sm text-muted transition-colors duration-300 group-hover:text-gold">
|
||||||
|
ادامه مطلب
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1">
|
||||||
|
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
hrefFor={(p) => `${basePath}?page=${p}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Reveal } from "@/components/Reveal";
|
import { Reveal } from "@/components/Reveal";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
import type { DisplayProject, PortfolioCategory } from "@/lib/api";
|
import type { DisplayProject, PortfolioCategory } from "@/lib/api";
|
||||||
|
|
||||||
const ALL_ID = "__all__";
|
const ALL_ID = "__all__";
|
||||||
|
const PAGE_SIZE = 9;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Category tabs above a grid — no search, click a tab to filter in place.
|
* Category tabs above a grid — no search, click a tab to filter in place.
|
||||||
@@ -25,6 +27,7 @@ export function PortfolioBrowser({
|
|||||||
categories: PortfolioCategory[];
|
categories: PortfolioCategory[];
|
||||||
}) {
|
}) {
|
||||||
const [activeId, setActiveId] = useState(ALL_ID);
|
const [activeId, setActiveId] = useState(ALL_ID);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const showTabs = categories.length > 1;
|
const showTabs = categories.length > 1;
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
@@ -32,6 +35,12 @@ export function PortfolioBrowser({
|
|||||||
[projects, 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{showTabs && (
|
{showTabs && (
|
||||||
@@ -84,8 +93,8 @@ export function PortfolioBrowser({
|
|||||||
پروژهای در این دسته یافت نشد.
|
پروژهای در این دسته یافت نشد.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mx-auto grid max-w-[1400px] gap-6 px-6 pb-24 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:px-12 lg:pb-32">
|
<ul className="mx-auto grid max-w-[1400px] gap-6 px-6 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:px-12">
|
||||||
{filtered.map((project, i) => (
|
{pageItems.map((project, i) => (
|
||||||
<Reveal as="li" key={project.slug} delay={(i % 6) * 70}>
|
<Reveal as="li" key={project.slug} delay={(i % 6) * 70}>
|
||||||
<Link href={`/portfolios/${project.slug}`} className="group block">
|
<Link href={`/portfolios/${project.slug}`} className="group block">
|
||||||
<div className="relative aspect-[3/4] overflow-hidden bg-ink">
|
<div className="relative aspect-[3/4] overflow-hidden bg-ink">
|
||||||
@@ -126,6 +135,10 @@ export function PortfolioBrowser({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function ProjectGalleryLightbox({ images, alt }: { images: string[]; alt:
|
|||||||
showPrev();
|
showPrev();
|
||||||
}}
|
}}
|
||||||
aria-label="تصویر قبلی"
|
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"
|
||||||
>
|
>
|
||||||
<ChevronIcon direction="prev" className="h-6 w-6" />
|
<ChevronIcon direction="prev" className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -126,7 +126,7 @@ export function ProjectGalleryLightbox({ images, alt }: { images: string[]; alt:
|
|||||||
showNext();
|
showNext();
|
||||||
}}
|
}}
|
||||||
aria-label="تصویر بعدی"
|
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"
|
||||||
>
|
>
|
||||||
<ChevronIcon direction="next" className="h-6 w-6" />
|
<ChevronIcon direction="next" className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
|
||||||
|
<path d={d} stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Link
|
||||||
|
key={ariaLabel}
|
||||||
|
href={hrefFor(target)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-current={current}
|
||||||
|
className={controlClass(current)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ariaLabel}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(target)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-current={current}
|
||||||
|
className={controlClass(current)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="صفحهبندی" className="mt-14 flex items-center justify-center gap-2 lg:mt-20">
|
||||||
|
{page === 1 ? (
|
||||||
|
<span className={controlClass(false, true)}>
|
||||||
|
<ChevronIcon direction="prev" className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
renderItem(prevPage, <ChevronIcon direction="prev" className="h-4 w-4" />, "صفحه قبل")
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pages.map((p) => renderItem(p, p, `صفحه ${p}`, p === page))}
|
||||||
|
|
||||||
|
{page === totalPages ? (
|
||||||
|
<span className={controlClass(false, true)}>
|
||||||
|
<ChevronIcon direction="next" className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
renderItem(nextPage, <ChevronIcon direction="next" className="h-4 w-4" />, "صفحه بعد")
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
+95
-1
@@ -31,7 +31,7 @@ export const nav = [
|
|||||||
{ label: "صفحه اصلی", href: "/" },
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
{ label: "خدمات ما", href: "/our-services" },
|
{ label: "خدمات ما", href: "/our-services" },
|
||||||
{ label: "پروژه ها", href: "/portfolios" },
|
{ label: "پروژه ها", href: "/portfolios" },
|
||||||
{ label: "مقالات و اخبار", href: "/news" },
|
{ label: "مقالات و اخبار", href: "/news-and-blog" },
|
||||||
{ label: "درباره ما", href: "/aboutus" },
|
{ label: "درباره ما", href: "/aboutus" },
|
||||||
{ label: "تماس با ما", href: "/contactus" },
|
{ label: "تماس با ما", href: "/contactus" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -294,6 +294,97 @@ export const projectVideos: Record<string, string> = {
|
|||||||
"item-old-589": "https://www.aparat.com/v/dnem1b3",
|
"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 = {
|
export type NewsItem = {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -321,3 +412,6 @@ export const news: NewsItem[] = [
|
|||||||
image: hayatFoundation,
|
image: hayatFoundation,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** No blog posts published anywhere yet — genuinely empty, not left out. */
|
||||||
|
export const blog: NewsItem[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user