feat: use mock fallbacks for CMS content

This commit is contained in:
2026-09-27 12:26:34 +03:30
parent c63d5132e0
commit 1979eaf19a
2 changed files with 272 additions and 0 deletions

View File

@@ -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
View 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;
}
}