diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index e845324..25f421b 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -4,17 +4,20 @@ import { notFound } from "next/navigation"; import { NewsArticleDetail } from "@/components/news/NewsArticleDetail"; import { SiteFooter } from "@/components/SiteFooter"; import { SiteHeader } from "@/components/SiteHeader"; -import { getBlogs, toDisplayNewsItem } from "@/lib/api"; +import { getBlogArticles, getBlogBySlug, toDisplayNewsItem } from "@/lib/api"; export const revalidate = 3600; -async function getBlogPosts() { - const posts = await getBlogs(); - return posts.filter((p) => p.type !== "news"); +async function getBlogArticleBySlug(slug: string) { + const item = await getBlogBySlug(slug); + if (!item || item.type === "news") { + return null; + } + return item; } export async function generateStaticParams() { - const posts = await getBlogPosts(); + const posts = await getBlogArticles(); return posts.map((item) => ({ slug: item.slug })); } @@ -24,8 +27,7 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; - const posts = await getBlogPosts(); - const item = posts.find((p) => p.slug === slug); + const item = await getBlogArticleBySlug(slug); if (!item) return {}; return { title: item.titleFa || item.title, description: item.abstract || item.titleFa }; } @@ -36,8 +38,7 @@ export default async function BlogDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; - const posts = await getBlogPosts(); - const item = posts.find((p) => p.slug === slug); + const item = await getBlogArticleBySlug(slug); if (!item) notFound(); return ( diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx index 966dd70..afa6862 100644 --- a/src/app/news/[slug]/page.tsx +++ b/src/app/news/[slug]/page.tsx @@ -4,10 +4,18 @@ import { notFound } from "next/navigation"; import { NewsArticleDetail } from "@/components/news/NewsArticleDetail"; import { SiteFooter } from "@/components/SiteFooter"; import { SiteHeader } from "@/components/SiteHeader"; -import { getBlogs, toDisplayNewsItem } from "@/lib/api"; +import { getBlogBySlug, getBlogs, toDisplayNewsItem } from "@/lib/api"; 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() { const posts = await getBlogs("news"); return posts.map((item) => ({ slug: item.slug })); @@ -19,8 +27,7 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; - const posts = await getBlogs("news"); - const item = posts.find((p) => p.slug === slug); + const item = await getNewsBySlug(slug); if (!item) return {}; return { title: item.titleFa || item.title, description: item.abstract || item.titleFa }; } @@ -31,8 +38,7 @@ export default async function NewsDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; - const posts = await getBlogs("news"); - const item = posts.find((p) => p.slug === slug); + const item = await getNewsBySlug(slug); if (!item) notFound(); return ( diff --git a/src/lib/api.ts b/src/lib/api.ts index be32eb9..0d02144 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -12,6 +12,8 @@ * 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 SITE_DOMAIN = "raoofigroup.com"; @@ -57,10 +59,9 @@ type PortfoliosResponse = { pageSize: number; }; -async function apiGet(path: string): Promise { - let res: Response; +async function apiFetch(path: string): Promise { try { - res = await fetch(`${API_BASE}${path}`, { + return await fetch(`${API_BASE}${path}`, { headers: { Accept: "application/json" }, next: { revalidate: REVALIDATE_SECONDS }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -68,6 +69,24 @@ async function apiGet(path: string): Promise { } catch (cause) { throw new Error(`meshkee API ${path} timed out or was unreachable`, { cause }); } +} + +async function apiGet(path: string): Promise { + const res = await apiFetch(path); + + if (!res.ok) { + throw new Error(`meshkee API ${path} responded ${res.status}`); + } + + return res.json() as Promise; +} + +async function apiGetOptional(path: string): Promise { + const res = await apiFetch(path); + + if (res.status === 404) { + return null; + } if (!res.ok) { throw new Error(`meshkee API ${path} responded ${res.status}`); @@ -77,7 +96,7 @@ async function apiGet(path: string): Promise { } /** All published portfolio items, every page merged, in the CMS's own order. */ -export async function getPortfolios(): Promise { +export const getPortfolios = cache(async (): Promise => { const first = await apiGet(`/tenants/${SITE_DOMAIN}/portfolios`); const pageCount = Math.ceil(first.total / first.pageSize); @@ -91,12 +110,21 @@ export async function getPortfolios(): Promise { .flatMap((page) => page.items) .filter((item) => item.status === "published") .sort((a, b) => a.sortOrder - b.sortOrder); -} +}); -export async function getPortfolioBySlug(slug: string): Promise { - const items = await getPortfolios(); - return items.find((item) => item.slug === slug) ?? null; -} +/** 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)}`, + ); + + const item = data?.portfolio; + if (!item || item.status !== "published") { + return null; + } + + return item; +}); 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`. */ -export async function getBlogs(type?: BlogType): Promise { +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); @@ -224,12 +252,27 @@ export async function getBlogs(type?: BlogType): Promise { .flatMap((page) => page.items) .filter((item) => item.status === "published") .sort((a, b) => a.sortOrder - b.sortOrder); -} +}); -export async function getBlogBySlug(slug: string): Promise { - const items = await getBlogs(); - return items.find((item) => item.slug === slug) ?? null; -} +/** 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)}`, + ); + + 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 => { + const posts = await getBlogs(); + return posts.filter((item) => item.type !== "news"); +}); export type DisplayNewsItem = { slug: string;