Reduce memory use by caching API calls and using slug endpoints.
Use React cache() for list/slug fetches, hit single-item portfolio/blog routes instead of loading full lists, and dedupe metadata + page requests on news/blog detail pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
9455ebe7cd
commit
f341b010c0
@@ -4,17 +4,20 @@ import { notFound } from "next/navigation";
|
|||||||
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
|
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
|
import { getBlogArticles, getBlogBySlug, toDisplayNewsItem } from "@/lib/api";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
async function getBlogPosts() {
|
async function getBlogArticleBySlug(slug: string) {
|
||||||
const posts = await getBlogs();
|
const item = await getBlogBySlug(slug);
|
||||||
return posts.filter((p) => p.type !== "news");
|
if (!item || item.type === "news") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const posts = await getBlogPosts();
|
const posts = await getBlogArticles();
|
||||||
return posts.map((item) => ({ slug: item.slug }));
|
return posts.map((item) => ({ slug: item.slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,8 +27,7 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const posts = await getBlogPosts();
|
const item = await getBlogArticleBySlug(slug);
|
||||||
const item = posts.find((p) => p.slug === slug);
|
|
||||||
if (!item) return {};
|
if (!item) return {};
|
||||||
return { title: item.titleFa || item.title, description: item.abstract || item.titleFa };
|
return { title: item.titleFa || item.title, description: item.abstract || item.titleFa };
|
||||||
}
|
}
|
||||||
@@ -36,8 +38,7 @@ export default async function BlogDetailPage({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const posts = await getBlogPosts();
|
const item = await getBlogArticleBySlug(slug);
|
||||||
const item = posts.find((p) => p.slug === slug);
|
|
||||||
if (!item) notFound();
|
if (!item) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { notFound } from "next/navigation";
|
|||||||
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
|
import { NewsArticleDetail } from "@/components/news/NewsArticleDetail";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { getBlogs, toDisplayNewsItem } from "@/lib/api";
|
import { getBlogBySlug, getBlogs, toDisplayNewsItem } from "@/lib/api";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
async function getNewsBySlug(slug: string) {
|
||||||
|
const item = await getBlogBySlug(slug);
|
||||||
|
if (!item || item.type !== "news") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const posts = await getBlogs("news");
|
const posts = await getBlogs("news");
|
||||||
return posts.map((item) => ({ slug: item.slug }));
|
return posts.map((item) => ({ slug: item.slug }));
|
||||||
@@ -19,8 +27,7 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const posts = await getBlogs("news");
|
const item = await getNewsBySlug(slug);
|
||||||
const item = posts.find((p) => p.slug === slug);
|
|
||||||
if (!item) return {};
|
if (!item) return {};
|
||||||
return { title: item.titleFa || item.title, description: item.abstract || item.titleFa };
|
return { title: item.titleFa || item.title, description: item.abstract || item.titleFa };
|
||||||
}
|
}
|
||||||
@@ -31,8 +38,7 @@ export default async function NewsDetailPage({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const posts = await getBlogs("news");
|
const item = await getNewsBySlug(slug);
|
||||||
const item = posts.find((p) => p.slug === slug);
|
|
||||||
if (!item) notFound();
|
if (!item) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+58
-15
@@ -12,6 +12,8 @@
|
|||||||
* to projects in their CMS, the filter picks them up with no code change.
|
* to projects in their CMS, the filter picks them up with no code change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
const API_BASE = "https://api.meshkee.com/api/v1";
|
const API_BASE = "https://api.meshkee.com/api/v1";
|
||||||
const SITE_DOMAIN = "raoofigroup.com";
|
const SITE_DOMAIN = "raoofigroup.com";
|
||||||
|
|
||||||
@@ -57,10 +59,9 @@ type PortfoliosResponse = {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function apiGet<T>(path: string): Promise<T> {
|
async function apiFetch(path: string): Promise<Response> {
|
||||||
let res: Response;
|
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${API_BASE}${path}`, {
|
return await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
next: { revalidate: REVALIDATE_SECONDS },
|
next: { revalidate: REVALIDATE_SECONDS },
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
@@ -68,6 +69,24 @@ async function apiGet<T>(path: string): Promise<T> {
|
|||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new Error(`meshkee API ${path} timed out or was unreachable`, { cause });
|
throw new Error(`meshkee API ${path} timed out or was unreachable`, { cause });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGet<T>(path: string): Promise<T> {
|
||||||
|
const res = await apiFetch(path);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`meshkee API ${path} responded ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGetOptional<T>(path: string): Promise<T | null> {
|
||||||
|
const res = await apiFetch(path);
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`meshkee API ${path} responded ${res.status}`);
|
throw new Error(`meshkee API ${path} responded ${res.status}`);
|
||||||
@@ -77,7 +96,7 @@ async function apiGet<T>(path: string): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** All published portfolio items, every page merged, in the CMS's own order. */
|
/** All published portfolio items, every page merged, in the CMS's own order. */
|
||||||
export async function getPortfolios(): Promise<ApiPortfolioItem[]> {
|
export const getPortfolios = cache(async (): Promise<ApiPortfolioItem[]> => {
|
||||||
const first = await apiGet<PortfoliosResponse>(`/tenants/${SITE_DOMAIN}/portfolios`);
|
const first = await apiGet<PortfoliosResponse>(`/tenants/${SITE_DOMAIN}/portfolios`);
|
||||||
const pageCount = Math.ceil(first.total / first.pageSize);
|
const pageCount = Math.ceil(first.total / first.pageSize);
|
||||||
|
|
||||||
@@ -91,12 +110,21 @@ export async function getPortfolios(): Promise<ApiPortfolioItem[]> {
|
|||||||
.flatMap((page) => page.items)
|
.flatMap((page) => page.items)
|
||||||
.filter((item) => item.status === "published")
|
.filter((item) => item.status === "published")
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function getPortfolioBySlug(slug: string): Promise<ApiPortfolioItem | null> {
|
/** Single portfolio by slug — avoids loading the full list on detail pages. */
|
||||||
const items = await getPortfolios();
|
export const getPortfolioBySlug = cache(async (slug: string): Promise<ApiPortfolioItem | null> => {
|
||||||
return items.find((item) => item.slug === slug) ?? null;
|
const data = await apiGetOptional<{ portfolio: ApiPortfolioItem }>(
|
||||||
}
|
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const item = data?.portfolio;
|
||||||
|
if (!item || item.status !== "published") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
export type PortfolioCategory = { id: string; name: string; count: number };
|
export type PortfolioCategory = { id: string; name: string; count: number };
|
||||||
|
|
||||||
@@ -207,7 +235,7 @@ type BlogsResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** All published blog/news items, every page merged. Optionally filtered by `type`. */
|
/** All published blog/news items, every page merged. Optionally filtered by `type`. */
|
||||||
export async function getBlogs(type?: BlogType): Promise<ApiBlogItem[]> {
|
export const getBlogs = cache(async (type?: BlogType): Promise<ApiBlogItem[]> => {
|
||||||
const query = type ? `?type=${type}` : "";
|
const query = type ? `?type=${type}` : "";
|
||||||
const first = await apiGet<BlogsResponse>(`/tenants/${SITE_DOMAIN}/blogs${query}`);
|
const first = await apiGet<BlogsResponse>(`/tenants/${SITE_DOMAIN}/blogs${query}`);
|
||||||
const pageCount = Math.ceil(first.total / first.pageSize);
|
const pageCount = Math.ceil(first.total / first.pageSize);
|
||||||
@@ -224,12 +252,27 @@ export async function getBlogs(type?: BlogType): Promise<ApiBlogItem[]> {
|
|||||||
.flatMap((page) => page.items)
|
.flatMap((page) => page.items)
|
||||||
.filter((item) => item.status === "published")
|
.filter((item) => item.status === "published")
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function getBlogBySlug(slug: string): Promise<ApiBlogItem | null> {
|
/** Single blog/news item by slug — avoids loading the full list on detail pages. */
|
||||||
const items = await getBlogs();
|
export const getBlogBySlug = cache(async (slug: string): Promise<ApiBlogItem | null> => {
|
||||||
return items.find((item) => item.slug === slug) ?? null;
|
const data = await apiGetOptional<{ blog: ApiBlogItem }>(
|
||||||
}
|
`/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const item = data?.blog;
|
||||||
|
if (!item || item.status !== "published") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Blog articles only (excludes news posts). */
|
||||||
|
export const getBlogArticles = cache(async (): Promise<ApiBlogItem[]> => {
|
||||||
|
const posts = await getBlogs();
|
||||||
|
return posts.filter((item) => item.type !== "news");
|
||||||
|
});
|
||||||
|
|
||||||
export type DisplayNewsItem = {
|
export type DisplayNewsItem = {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user