diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..641a5d7 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Runtime env (set in PM2 ecosystem on the websites VM — not committed) + +# Secret for POST /api/revalidate (on-demand cache purge after CMS publish) +REVALIDATE_SECRET= diff --git a/src/app/aboutus/page.tsx b/src/app/aboutus/page.tsx index 3ad647c..bd408ad 100644 --- a/src/app/aboutus/page.tsx +++ b/src/app/aboutus/page.tsx @@ -14,7 +14,9 @@ import { Reveal } from "@/components/Reveal"; import { SiteFooter } from "@/components/SiteFooter"; import { SiteHeader } from "@/components/SiteHeader"; import { aboutPage } from "@/data/site"; -import { getPortfolios, toDisplayProject } from "@/lib/api"; +import { getRecentPortfolios, toDisplayProject } from "@/lib/api"; + +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS export const metadata: Metadata = { title: "درباره ما", @@ -23,7 +25,7 @@ export const metadata: Metadata = { }; export default async function AboutPage() { - const portfolios = await getPortfolios(); + const portfolios = await getRecentPortfolios(12); const displayProjects = portfolios.map(toDisplayProject); return ( diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts new file mode 100644 index 0000000..a8662df --- /dev/null +++ b/src/app/api/revalidate/route.ts @@ -0,0 +1,69 @@ +import { revalidatePath, revalidateTag } from "next/cache"; +import { NextRequest, NextResponse } from "next/server"; + +type RevalidateBody = { + /** Revalidate a fetch cache tag (portfolios, blogs, portfolio:slug, blog:slug, …). */ + tag?: string; + /** Revalidate a path (/portfolios/foo, /, …). */ + path?: string; + /** Shorthand: portfolios | blogs | all */ + scope?: "portfolios" | "blogs" | "all"; +}; + +/** + * On-demand cache purge — call after CMS publish/update. + * + * POST /api/revalidate + * Header: x-revalidate-secret: + * Body (JSON, optional): { "scope": "all" } | { "tag": "portfolios" } | { "path": "/portfolios/item" } + * + * nginx may still serve cached HTML for up to its TTL (5m) unless purged separately. + */ +export async function POST(req: NextRequest) { + const secret = process.env.REVALIDATE_SECRET; + if (!secret) { + return NextResponse.json({ ok: false, error: "REVALIDATE_SECRET not configured" }, { status: 503 }); + } + + const provided = + req.headers.get("x-revalidate-secret") ?? req.nextUrl.searchParams.get("secret"); + if (provided !== secret) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + let body: RevalidateBody = {}; + try { + body = (await req.json()) as RevalidateBody; + } catch { + body = {}; + } + + const scope = body.scope ?? "all"; + const revalidated: string[] = []; + + if (body.tag) { + revalidateTag(body.tag); + revalidated.push(`tag:${body.tag}`); + } else if (body.path) { + revalidatePath(body.path); + revalidated.push(`path:${body.path}`); + } else if (scope === "portfolios" || scope === "all") { + revalidateTag("portfolios"); + revalidatePath("/"); + revalidatePath("/portfolios"); + revalidatePath("/aboutus"); + revalidatePath("/our-services"); + revalidated.push("scope:portfolios"); + } + + if (scope === "blogs" || scope === "all") { + revalidateTag("blogs"); + revalidateTag("blogs:news"); + revalidatePath("/news"); + revalidatePath("/blog"); + revalidatePath("/news-and-blog"); + revalidated.push("scope:blogs"); + } + + return NextResponse.json({ ok: true, revalidated, at: new Date().toISOString() }); +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 25f421b..1d7920a 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -4,9 +4,9 @@ import { notFound } from "next/navigation"; import { NewsArticleDetail } from "@/components/news/NewsArticleDetail"; import { SiteFooter } from "@/components/SiteFooter"; import { SiteHeader } from "@/components/SiteHeader"; -import { getBlogArticles, getBlogBySlug, toDisplayNewsItem } from "@/lib/api"; +import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api"; -export const revalidate = 3600; +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS async function getBlogArticleBySlug(slug: string) { const item = await getBlogBySlug(slug); @@ -17,8 +17,8 @@ async function getBlogArticleBySlug(slug: string) { } export async function generateStaticParams() { - const posts = await getBlogArticles(); - return posts.map((item) => ({ slug: item.slug })); + const slugs = await getBlogSlugs(); + return slugs.map((slug) => ({ slug })); } export async function generateMetadata({ diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 67b11e7..7dc3609 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -16,7 +16,7 @@ export const metadata: Metadata = { description: "فهرست کامل مقالات منتشرشده‌ی گروه ساختمانی رئوفی.", }; -export const revalidate = 3600; +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS export default async function BlogListPage({ searchParams, diff --git a/src/app/news-and-blog/page.tsx b/src/app/news-and-blog/page.tsx index ec4797c..3512051 100644 --- a/src/app/news-and-blog/page.tsx +++ b/src/app/news-and-blog/page.tsx @@ -18,7 +18,7 @@ export const metadata: Metadata = { description: "آخرین اخبار، رویدادها و مقالات گروه ساختمانی رئوفی.", }; -export const revalidate = 3600; +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS export default async function NewsAndBlogPage() { const posts = await getBlogs(); diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx index afa6862..a9ec07d 100644 --- a/src/app/news/[slug]/page.tsx +++ b/src/app/news/[slug]/page.tsx @@ -4,9 +4,9 @@ import { notFound } from "next/navigation"; import { NewsArticleDetail } from "@/components/news/NewsArticleDetail"; import { SiteFooter } from "@/components/SiteFooter"; import { SiteHeader } from "@/components/SiteHeader"; -import { getBlogBySlug, getBlogs, toDisplayNewsItem } from "@/lib/api"; +import { getBlogBySlug, getBlogSlugs, toDisplayNewsItem } from "@/lib/api"; -export const revalidate = 3600; +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS async function getNewsBySlug(slug: string) { const item = await getBlogBySlug(slug); @@ -17,8 +17,8 @@ async function getNewsBySlug(slug: string) { } export async function generateStaticParams() { - const posts = await getBlogs("news"); - return posts.map((item) => ({ slug: item.slug })); + const slugs = await getBlogSlugs("news"); + return slugs.map((slug) => ({ slug })); } export async function generateMetadata({ diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx index 7659f0c..2dacd35 100644 --- a/src/app/news/page.tsx +++ b/src/app/news/page.tsx @@ -16,7 +16,7 @@ export const metadata: Metadata = { description: "فهرست کامل اخبار و رویدادهای منتشرشده‌ی گروه ساختمانی رئوفی.", }; -export const revalidate = 3600; +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS export default async function NewsListPage({ searchParams, diff --git a/src/app/our-services/page.tsx b/src/app/our-services/page.tsx index ba53888..6dad94d 100644 --- a/src/app/our-services/page.tsx +++ b/src/app/our-services/page.tsx @@ -11,7 +11,9 @@ import { ProjectGallery } from "@/components/shared/ProjectGallery"; import { SiteFooter } from "@/components/SiteFooter"; import { SiteHeader } from "@/components/SiteHeader"; import { servicesPage } from "@/data/site"; -import { getPortfolios, toDisplayProject } from "@/lib/api"; +import { getRecentPortfolios, toDisplayProject } from "@/lib/api"; + +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS export const metadata: Metadata = { title: "خدمات ما", @@ -20,7 +22,7 @@ export const metadata: Metadata = { }; export default async function ServicesPage() { - const portfolios = await getPortfolios(); + const portfolios = await getRecentPortfolios(12); const displayProjects = portfolios.map(toDisplayProject); return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index 075fcea..20a1fd2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,7 +8,9 @@ import { Services } from "@/components/Services"; import { SiteFooter } from "@/components/SiteFooter"; import { SiteHeader } from "@/components/SiteHeader"; import { contact, site } from "@/data/site"; -import { getBlogs, getPortfolios, toDisplayNewsItem, toDisplayProject } from "@/lib/api"; +import { getRecentNews, getRecentPortfolios, toDisplayNewsItem, toDisplayProject } from "@/lib/api"; + +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS const organizationJsonLd = { "@context": "https://schema.org", @@ -29,9 +31,9 @@ const organizationJsonLd = { }; export default async function HomePage() { - const [portfolios, newsPosts] = await Promise.all([getPortfolios(), getBlogs("news")]); + const [portfolios, newsPosts] = await Promise.all([getRecentPortfolios(12), getRecentNews(3)]); const displayProjects = portfolios.map(toDisplayProject); - const displayNews = newsPosts.slice(0, 3).map(toDisplayNewsItem); + const displayNews = newsPosts.map(toDisplayNewsItem); return ( <> diff --git a/src/app/portfolios/[slug]/page.tsx b/src/app/portfolios/[slug]/page.tsx index 41549e3..2933681 100644 --- a/src/app/portfolios/[slug]/page.tsx +++ b/src/app/portfolios/[slug]/page.tsx @@ -9,13 +9,13 @@ import { PageHero } from "@/components/shared/PageHero"; import { SiteFooter } from "@/components/SiteFooter"; import { SiteHeader } from "@/components/SiteHeader"; import { portfoliosPage, projectVideos } from "@/data/site"; -import { getPortfolioBySlug, getPortfolios, toDisplayProject } from "@/lib/api"; +import { getPortfolioBySlug, getPortfolioSlugs, toDisplayProject } from "@/lib/api"; -export const revalidate = 3600; +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS export async function generateStaticParams() { - const portfolios = await getPortfolios(); - return portfolios.map((item) => ({ slug: item.slug })); + const slugs = await getPortfolioSlugs(); + return slugs.map((slug) => ({ slug })); } export async function generateMetadata({ diff --git a/src/app/portfolios/page.tsx b/src/app/portfolios/page.tsx index 68be5e2..7168db5 100644 --- a/src/app/portfolios/page.tsx +++ b/src/app/portfolios/page.tsx @@ -16,7 +16,7 @@ export const metadata: Metadata = { description: "نمونه‌کارهای منتشرشده‌ی گروه ساختمانی رئوفی، از پروژه‌های مسکونی تا برج‌های اداری-تجاری.", }; -export const revalidate = 3600; +export const revalidate = 300; // keep in sync with api.ts PAGE_REVALIDATE_SECONDS export default async function PortfoliosPage() { const portfolios = await getPortfolios(); diff --git a/src/lib/api.ts b/src/lib/api.ts index 0d02144..f7bc61c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -17,8 +17,15 @@ import { cache } from "react"; const API_BASE = "https://api.meshkee.com/api/v1"; const SITE_DOMAIN = "raoofigroup.com"; -/** How long fetched portfolio data is cached before Next.js revalidates it. */ -const REVALIDATE_SECONDS = 3600; +/** Page-level ISR — kept in sync with fetch revalidate below. */ +export const PAGE_REVALIDATE_SECONDS = 300; + +/** List endpoints refresh more often; detail payloads are heavier so TTL is slightly longer. */ +const REVALIDATE_LIST_SECONDS = PAGE_REVALIDATE_SECONDS; +const REVALIDATE_DETAIL_SECONDS = 600; + +/** Default page size when paginating tenant list endpoints. */ +const LIST_PAGE_SIZE = 24; /** * api.meshkee.com has been observed to hang for minutes at a time rather than @@ -59,11 +66,19 @@ type PortfoliosResponse = { pageSize: number; }; -async function apiFetch(path: string): Promise { +type FetchOptions = { + revalidate?: number; + tags?: string[]; +}; + +async function apiFetch(path: string, options?: FetchOptions): Promise { try { return await fetch(`${API_BASE}${path}`, { headers: { Accept: "application/json" }, - next: { revalidate: REVALIDATE_SECONDS }, + next: { + revalidate: options?.revalidate ?? REVALIDATE_LIST_SECONDS, + tags: options?.tags, + }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (cause) { @@ -71,8 +86,8 @@ async function apiFetch(path: string): Promise { } } -async function apiGet(path: string): Promise { - const res = await apiFetch(path); +async function apiGet(path: string, options?: FetchOptions): Promise { + const res = await apiFetch(path, options); if (!res.ok) { throw new Error(`meshkee API ${path} responded ${res.status}`); @@ -81,8 +96,8 @@ async function apiGet(path: string): Promise { return res.json() as Promise; } -async function apiGetOptional(path: string): Promise { - const res = await apiFetch(path); +async function apiGetOptional(path: string, options?: FetchOptions): Promise { + const res = await apiFetch(path, options); if (res.status === 404) { return null; @@ -95,27 +110,81 @@ async function apiGetOptional(path: string): Promise { return res.json() as Promise; } -/** All published portfolio items, every page merged, in the CMS's own order. */ -export const getPortfolios = cache(async (): Promise => { - const first = await apiGet(`/tenants/${SITE_DOMAIN}/portfolios`); +/** Drop body HTML and gallery arrays from list responses — not needed for cards/grids. */ +function stripListHeavyFields(item: T): T { + return { + ...item, + mainTextHtml: "", + gallery: [], + galleryMediaIds: [], + }; +} + +async function fetchAllPages( + basePath: string, + options?: FetchOptions, +): Promise { + const first = await apiGet(basePath, options); 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}/portfolios?page=${i + 2}`), - ), + Array.from({ length: Math.max(0, pageCount - 1) }, (_, i) => { + const separator = basePath.includes("?") ? "&" : "?"; + return apiGet(`${basePath}${separator}page=${i + 2}`, options); + }), ); - 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. */ +export const getPortfolios = cache(async (): Promise => { + const items = await fetchAllPages( + `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`, + { tags: ["portfolios"] }, + ); + + return (items as ApiPortfolioItem[]) .filter((item) => item.status === "published") - .sort((a, b) => a.sortOrder - b.sortOrder); + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(stripListHeavyFields); +}); + +/** Recent published portfolios for home / about / services carousels. */ +export const getRecentPortfolios = cache(async (limit = 12): Promise => { + const pageSize = Math.min(Math.max(limit, 1), 50); + const first = await apiGet( + `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${pageSize}`, + { tags: ["portfolios"] }, + ); + + return first.items + .filter((item) => item.status === "published") + .sort((a, b) => a.sortOrder - b.sortOrder) + .slice(0, limit) + .map(stripListHeavyFields); +}); + +/** Slugs only — for generateStaticParams without retaining full list payloads. */ +export const getPortfolioSlugs = cache(async (): Promise => { + const items = await fetchAllPages( + `/tenants/${SITE_DOMAIN}/portfolios?page=1&pageSize=${LIST_PAGE_SIZE}`, + { tags: ["portfolios"] }, + ); + + return (items as ApiPortfolioItem[]) + .filter((item) => item.status === "published") + .map((item) => item.slug); }); /** Single portfolio by slug — avoids loading the full list on detail pages. */ export const getPortfolioBySlug = cache(async (slug: string): Promise => { const data = await apiGetOptional<{ portfolio: ApiPortfolioItem }>( `/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`, + { + revalidate: REVALIDATE_DETAIL_SECONDS, + tags: ["portfolios", `portfolio:${slug}`], + }, ); const item = data?.portfolio; @@ -234,30 +303,57 @@ type BlogsResponse = { pageSize: number; }; +function blogListPath(type?: BlogType): string { + const query = type ? `?type=${type}` : ""; + const prefix = `/tenants/${SITE_DOMAIN}/blogs${query}`; + const separator = query ? "&" : "?"; + return `${prefix}${separator}page=1&pageSize=${LIST_PAGE_SIZE}`; +} + /** All published blog/news items, every page merged. Optionally filtered by `type`. */ export const getBlogs = cache(async (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 tag = type ? `blogs:${type}` : "blogs"; + const items = await fetchAllPages(blogListPath(type), { tags: [tag, "blogs"] }); - 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 (items as ApiBlogItem[]) + .filter((item) => item.status === "published") + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(stripListHeavyFields); +}); + +/** Recent news posts for the home page carousel. */ +export const getRecentNews = cache(async (limit = 3): Promise => { + const pageSize = Math.min(Math.max(limit, 1), 50); + const first = await apiGet( + `/tenants/${SITE_DOMAIN}/blogs?type=news&page=1&pageSize=${pageSize}`, + { tags: ["blogs", "blogs:news"] }, ); - return [first, ...rest] - .flatMap((page) => page.items) + return first.items .filter((item) => item.status === "published") - .sort((a, b) => a.sortOrder - b.sortOrder); + .sort((a, b) => a.sortOrder - b.sortOrder) + .slice(0, limit) + .map(stripListHeavyFields); +}); + +/** Slugs for generateStaticParams. */ +export const getBlogSlugs = cache(async (type?: BlogType): Promise => { + const tag = type ? `blogs:${type}` : "blogs"; + const items = await fetchAllPages(blogListPath(type), { tags: [tag, "blogs"] }); + + return (items as ApiBlogItem[]) + .filter((item) => item.status === "published" && (type ? item.type === type : item.type !== "news")) + .map((item) => item.slug); }); /** Single blog/news item by slug — avoids loading the full list on detail pages. */ export const getBlogBySlug = cache(async (slug: string): Promise => { const data = await apiGetOptional<{ blog: ApiBlogItem }>( `/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`, + { + revalidate: REVALIDATE_DETAIL_SECONDS, + tags: ["blogs", `blog:${slug}`], + }, ); const item = data?.blog;