feat: use mock fallbacks for CMS content
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
|
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:8080
|
NEXT_PUBLIC_SITE_URL=http://localhost:8080
|
||||||
|
CONTENT_CMS_ENABLED=false
|
||||||
|
|||||||
271
src/lib/content-cms.ts
Normal file
271
src/lib/content-cms.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { apiBaseUrl } from "@/lib/site";
|
||||||
|
|
||||||
|
export type ContentPageSlug = "home" | "about" | "events" | "blog";
|
||||||
|
|
||||||
|
export type ContentImage = {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentSeo = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image: ContentImage | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SiteContent = {
|
||||||
|
official_name: string;
|
||||||
|
short_name: string;
|
||||||
|
faculty_subline: string;
|
||||||
|
description: string;
|
||||||
|
logo: ContentImage | null;
|
||||||
|
navigation: Record<ContentPageSlug, string>;
|
||||||
|
contacts: {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
telegram: string;
|
||||||
|
instagram: string;
|
||||||
|
website: string;
|
||||||
|
};
|
||||||
|
address: string;
|
||||||
|
footer_copy: string;
|
||||||
|
seo: ContentSeo;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageBase = {
|
||||||
|
title: string;
|
||||||
|
intro: string;
|
||||||
|
seo: ContentSeo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HomeContentPage = PageBase & {
|
||||||
|
slug: "home";
|
||||||
|
hero_heading: string;
|
||||||
|
hero_body: string;
|
||||||
|
hero_image: ContentImage | null;
|
||||||
|
hero_action_label: string;
|
||||||
|
featured_events_heading: string;
|
||||||
|
featured_events_intro: string;
|
||||||
|
featured_events_link_label: string;
|
||||||
|
featured_posts_heading: string;
|
||||||
|
featured_posts_intro: string;
|
||||||
|
featured_posts_link_label: string;
|
||||||
|
participation_heading: string;
|
||||||
|
participation_body: string;
|
||||||
|
participation_image: ContentImage | null;
|
||||||
|
participation_action_label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AboutContentItem = {
|
||||||
|
kind: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AboutContentPage = PageBase & {
|
||||||
|
slug: "about";
|
||||||
|
identity_heading: string;
|
||||||
|
identity_body: string;
|
||||||
|
mission_heading: string;
|
||||||
|
mission_body: string;
|
||||||
|
activities_heading: string;
|
||||||
|
activities_body: string;
|
||||||
|
values_heading: string;
|
||||||
|
values_body: string;
|
||||||
|
committees_heading: string;
|
||||||
|
committees_body: string;
|
||||||
|
affiliations_heading: string;
|
||||||
|
affiliations_body: string;
|
||||||
|
faq_heading: string;
|
||||||
|
contact_heading: string;
|
||||||
|
items: AboutContentItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventsContentPage = PageBase & { slug: "events" };
|
||||||
|
export type BlogContentPage = PageBase & { slug: "blog" };
|
||||||
|
export type ListingContentPage = EventsContentPage | BlogContentPage;
|
||||||
|
|
||||||
|
export type CmsContentPage = HomeContentPage | AboutContentPage | ListingContentPage;
|
||||||
|
|
||||||
|
const CMS_ENABLED = process.env.CONTENT_CMS_ENABLED === "true";
|
||||||
|
|
||||||
|
export const isContentCmsEnabled = () => CMS_ENABLED;
|
||||||
|
|
||||||
|
type ContentPageFor<Slug extends ContentPageSlug> = Extract<CmsContentPage, { slug: Slug }>;
|
||||||
|
|
||||||
|
const MOCK_SITE_CONTENT: SiteContent = {
|
||||||
|
official_name: "نام انجمن نمونه",
|
||||||
|
short_name: "انجمن نمونه",
|
||||||
|
faculty_subline: "دانشکده نمونه",
|
||||||
|
description: "این متن نمونه است و پس از ورود محتوای اصلی از پنل مدیریت جایگزین میشود.",
|
||||||
|
logo: null,
|
||||||
|
navigation: { home: "خانه", about: "درباره ما", events: "رویدادها", blog: "بلاگ" },
|
||||||
|
contacts: {
|
||||||
|
email: "contact@example.invalid",
|
||||||
|
phone: "0000000000",
|
||||||
|
telegram: "https://example.invalid/telegram",
|
||||||
|
instagram: "https://example.invalid/instagram",
|
||||||
|
website: "https://example.invalid",
|
||||||
|
},
|
||||||
|
address: "نشانی نمونه",
|
||||||
|
footer_copy: "متن نمونه پابرگ؛ محتوای نهایی را از پنل مدیریت وارد کنید.",
|
||||||
|
seo: {
|
||||||
|
title: "عنوان نمونه سایت",
|
||||||
|
description: "توضیحات نمونه برای موتورهای جستوجو.",
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_PAGE_CONTENT: Record<ContentPageSlug, CmsContentPage> = {
|
||||||
|
home: {
|
||||||
|
slug: "home",
|
||||||
|
title: "عنوان نمونه صفحه اصلی",
|
||||||
|
intro: "این متن نمونه صفحه اصلی است و پس از ورود محتوای اصلی جایگزین میشود.",
|
||||||
|
seo: {
|
||||||
|
title: "عنوان نمونه صفحه اصلی",
|
||||||
|
description: "توضیحات نمونه صفحه اصلی برای موتورهای جستوجو.",
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
hero_heading: "عنوان نمونه صفحه اصلی",
|
||||||
|
hero_body: "توضیحات نمونه برای معرفی انجمن و فعالیتهای آن.",
|
||||||
|
hero_image: null,
|
||||||
|
hero_action_label: "مشاهده نمونهها",
|
||||||
|
featured_events_heading: "رویدادهای نمونه",
|
||||||
|
featured_events_intro: "توضیحات نمونه برای بخش رویدادها.",
|
||||||
|
featured_events_link_label: "همه رویدادها",
|
||||||
|
featured_posts_heading: "نوشتههای نمونه",
|
||||||
|
featured_posts_intro: "توضیحات نمونه برای بخش نوشتهها.",
|
||||||
|
featured_posts_link_label: "همه نوشتهها",
|
||||||
|
participation_heading: "به ما بپیوندید",
|
||||||
|
participation_body: "این متن نمونه راههای مشارکت را توضیح میدهد.",
|
||||||
|
participation_image: null,
|
||||||
|
participation_action_label: "اطلاعات بیشتر",
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
slug: "about",
|
||||||
|
title: "عنوان نمونه صفحه درباره ما",
|
||||||
|
intro: "این متن نمونه معرفی انجمن است و بعداً از پنل مدیریت قابل ویرایش است.",
|
||||||
|
seo: {
|
||||||
|
title: "عنوان نمونه صفحه درباره ما",
|
||||||
|
description: "توضیحات نمونه صفحه درباره ما برای موتورهای جستوجو.",
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
identity_heading: "معرفی نمونه",
|
||||||
|
identity_body: "اطلاعات نمونه درباره هویت انجمن.",
|
||||||
|
mission_heading: "ماموریت نمونه",
|
||||||
|
mission_body: "توضیحات نمونه درباره ماموریت انجمن.",
|
||||||
|
activities_heading: "فعالیتهای نمونه",
|
||||||
|
activities_body: "توضیحات نمونه درباره فعالیتها.",
|
||||||
|
values_heading: "ارزشهای نمونه",
|
||||||
|
values_body: "یادگیری، همکاری و مسئولیتپذیری نمونه.",
|
||||||
|
committees_heading: "کمیتههای نمونه",
|
||||||
|
committees_body: "اطلاعات نمونه درباره کمیتهها.",
|
||||||
|
affiliations_heading: "همکاریهای نمونه",
|
||||||
|
affiliations_body: "اطلاعات نمونه درباره گروههای همکار.",
|
||||||
|
faq_heading: "پرسشهای نمونه",
|
||||||
|
contact_heading: "راههای ارتباطی نمونه",
|
||||||
|
items: [
|
||||||
|
{ kind: "mission", title: "ماموریت نمونه", text: "این متن نمونه برای توضیح ماموریت انجمن است.", position: 0 },
|
||||||
|
{ kind: "activities", title: "فعالیت نمونه", text: "این متن نمونه یکی از فعالیتهای انجمن را معرفی میکند.", position: 0 },
|
||||||
|
{ kind: "values", title: "ارزش نمونه", text: "این متن نمونه یکی از ارزشهای انجمن را توضیح میدهد.", position: 0 },
|
||||||
|
{ kind: "committees", title: "کمیته نمونه", text: "این متن نمونه برای معرفی یک کمیته است.", position: 0 },
|
||||||
|
{ kind: "affiliations", title: "همکاری نمونه", text: "این متن نمونه برای معرفی یک گروه همکار است.", position: 0 },
|
||||||
|
{ kind: "faq", title: "پرسش نمونه", text: "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید.", position: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
slug: "events",
|
||||||
|
title: "عنوان نمونه رویدادها",
|
||||||
|
intro: "این متن نمونه برای معرفی فهرست رویدادهاست.",
|
||||||
|
seo: {
|
||||||
|
title: "عنوان نمونه رویدادها",
|
||||||
|
description: "توضیحات نمونه صفحه رویدادها برای موتورهای جستوجو.",
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blog: {
|
||||||
|
slug: "blog",
|
||||||
|
title: "عنوان نمونه بلاگ",
|
||||||
|
intro: "این متن نمونه برای معرفی نوشتههای بلاگ است.",
|
||||||
|
seo: {
|
||||||
|
title: "عنوان نمونه بلاگ",
|
||||||
|
description: "توضیحات نمونه صفحه بلاگ برای موتورهای جستوجو.",
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fillEmptyFieldsWithMock<T>(content: T, mock: T): T {
|
||||||
|
if (content === null || content === undefined) return mock;
|
||||||
|
if (typeof content === "string") return (content.trim() ? content : mock) as T;
|
||||||
|
if (Array.isArray(content)) return (content.length ? content : mock) as T;
|
||||||
|
if (
|
||||||
|
typeof content === "object" &&
|
||||||
|
mock !== null &&
|
||||||
|
typeof mock === "object" &&
|
||||||
|
!Array.isArray(mock)
|
||||||
|
) {
|
||||||
|
const current = content as Record<string, unknown>;
|
||||||
|
const fallback = mock as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(fallback).map(([key, mockValue]) => [
|
||||||
|
key,
|
||||||
|
key in current ? fillEmptyFieldsWithMock(current[key], mockValue) : mockValue,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPage<Slug extends ContentPageSlug>(slug: Slug): ContentPageFor<Slug> {
|
||||||
|
return MOCK_PAGE_CONTENT[slug] as ContentPageFor<Slug>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContentCmsError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(path: string, status: number) {
|
||||||
|
super(`Content CMS request failed for ${path}: ${status}`);
|
||||||
|
this.name = "ContentCmsError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestContent<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api/v1/content${path}`, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ContentCmsError(path, response.status);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCmsSiteContent(): Promise<SiteContent> {
|
||||||
|
if (!CMS_ENABLED) return MOCK_SITE_CONTENT;
|
||||||
|
try {
|
||||||
|
const content = await requestContent<SiteContent>("/site");
|
||||||
|
return fillEmptyFieldsWithMock(content, MOCK_SITE_CONTENT);
|
||||||
|
} catch {
|
||||||
|
return MOCK_SITE_CONTENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCmsPageContent<Slug extends ContentPageSlug>(
|
||||||
|
slug: Slug,
|
||||||
|
): Promise<ContentPageFor<Slug>> {
|
||||||
|
const fallback = mockPage(slug);
|
||||||
|
if (!CMS_ENABLED) return fallback;
|
||||||
|
try {
|
||||||
|
const page = await requestContent<CmsContentPage>(`/pages/${slug}`);
|
||||||
|
return page.slug === slug
|
||||||
|
? fillEmptyFieldsWithMock(page as ContentPageFor<Slug>, fallback)
|
||||||
|
: fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user