Tune caching: shorter revalidate, lighter fetches, on-demand purge.
Use paginated list fetches with stripped list payloads, recent-item helpers for carousels, fetch tags + POST /api/revalidate, and 5-minute page ISR instead of 1 hour. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f341b010c0
commit
02c7dd51d2
@@ -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=
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: <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() });
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+5
-3
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
+125
-29
@@ -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<Response> {
|
||||
type FetchOptions = {
|
||||
revalidate?: number;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
async function apiFetch(path: string, options?: FetchOptions): Promise<Response> {
|
||||
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<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await apiFetch(path);
|
||||
async function apiGet<T>(path: string, options?: FetchOptions): Promise<T> {
|
||||
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<T>(path: string): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function apiGetOptional<T>(path: string): Promise<T | null> {
|
||||
const res = await apiFetch(path);
|
||||
async function apiGetOptional<T>(path: string, options?: FetchOptions): Promise<T | null> {
|
||||
const res = await apiFetch(path, options);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
@@ -95,27 +110,81 @@ async function apiGetOptional<T>(path: string): Promise<T | null> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** All published portfolio items, every page merged, in the CMS's own order. */
|
||||
export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
|
||||
const first = await apiGet<PortfoliosResponse>(`/tenants/${SITE_DOMAIN}/portfolios`);
|
||||
/** Drop body HTML and gallery arrays from list responses — not needed for cards/grids. */
|
||||
function stripListHeavyFields<T extends ApiPortfolioItem | ApiBlogItem>(item: T): T {
|
||||
return {
|
||||
...item,
|
||||
mainTextHtml: "",
|
||||
gallery: [],
|
||||
galleryMediaIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAllPages<T extends { items: unknown[]; total: number; pageSize: number }>(
|
||||
basePath: string,
|
||||
options?: FetchOptions,
|
||||
): Promise<T["items"]> {
|
||||
const first = await apiGet<T>(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<PortfoliosResponse>(`/tenants/${SITE_DOMAIN}/portfolios?page=${i + 2}`),
|
||||
),
|
||||
Array.from({ length: Math.max(0, pageCount - 1) }, (_, i) => {
|
||||
const separator = basePath.includes("?") ? "&" : "?";
|
||||
return apiGet<T>(`${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<ApiPortfolioItem[]> => {
|
||||
const items = await fetchAllPages<PortfoliosResponse>(
|
||||
`/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<ApiPortfolioItem[]> => {
|
||||
const pageSize = Math.min(Math.max(limit, 1), 50);
|
||||
const first = await apiGet<PortfoliosResponse>(
|
||||
`/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<string[]> => {
|
||||
const items = await fetchAllPages<PortfoliosResponse>(
|
||||
`/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<ApiPortfolioItem | null> => {
|
||||
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<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 tag = type ? `blogs:${type}` : "blogs";
|
||||
const items = await fetchAllPages<BlogsResponse>(blogListPath(type), { tags: [tag, "blogs"] });
|
||||
|
||||
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 (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<ApiBlogItem[]> => {
|
||||
const pageSize = Math.min(Math.max(limit, 1), 50);
|
||||
const first = await apiGet<BlogsResponse>(
|
||||
`/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<string[]> => {
|
||||
const tag = type ? `blogs:${type}` : "blogs";
|
||||
const items = await fetchAllPages<BlogsResponse>(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<ApiBlogItem | null> => {
|
||||
const data = await apiGetOptional<{ blog: ApiBlogItem }>(
|
||||
`/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`,
|
||||
{
|
||||
revalidate: REVALIDATE_DETAIL_SECONDS,
|
||||
tags: ["blogs", `blog:${slug}`],
|
||||
},
|
||||
);
|
||||
|
||||
const item = data?.blog;
|
||||
|
||||
Reference in New Issue
Block a user