feat(content): public Blog + Learn sections and static CMS pages (full-stack)
Adds the missing public-facing content pages and their admin authoring, all powered by the existing content-svc Blog entity discriminated by `kind`. Backend (content-svc): - BlogKind enum += Learn, Page (reuses Blog CRUD/SEO/slug/publish for all three). - SQL migration services/content/migrations/001_blog_kind_learn_page.sql (ALTER TYPE content.blog_kind ADD VALUE 'Learn','Page'). Frontend (public, Next.js): - lib/content-api.ts: fetchArticles(kind) / fetchArticle(slug) / fetchPage(slug) with safe empty/null fallbacks. - components/content: article-ui (card/list/detail + RTL prose), CmsPageContent, CmsRoute (admin-authored page or localized built-in fallback copy). - Routes: /blog, /blog/[slug], /learn, /learn/[slug] and static pages /about /contact /careers /privacy /terms /cookies /help. - Navbar "tutorials" → /learn; all footer links now resolve. Admin: - AdminResource: new `fixedValues` option (injects kind on create/update). - learnConfig (kind=Learn) + pagesConfig (kind=Page) reuse the /v1/blogs endpoint; /admin/learn + /admin/pages routes + nav items. i18n: blog, learn and 7 *Page namespaces added to both fa.json and en.json (verified key parity); admin nav labels learn/pages. Frontend tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -187,6 +187,59 @@
|
|||||||
"socialLinkedIn": "LinkedIn",
|
"socialLinkedIn": "LinkedIn",
|
||||||
"socialYouTube": "YouTube"
|
"socialYouTube": "YouTube"
|
||||||
},
|
},
|
||||||
|
"blog": {
|
||||||
|
"metaTitle": "FlatRender Blog",
|
||||||
|
"metaDescription": "Articles, news and guides on making videos and images.",
|
||||||
|
"pageTitle": "Blog",
|
||||||
|
"pageDescription": "The latest articles, news and ideas on creating videos and images.",
|
||||||
|
"readMore": "Read more",
|
||||||
|
"views": "views",
|
||||||
|
"empty": "No articles have been published yet."
|
||||||
|
},
|
||||||
|
"learn": {
|
||||||
|
"metaTitle": "Learn FlatRender",
|
||||||
|
"metaDescription": "Step-by-step tutorials for making professional videos and images.",
|
||||||
|
"pageTitle": "Learn",
|
||||||
|
"pageDescription": "Step-by-step tutorials and practical guides for getting the most out of FlatRender.",
|
||||||
|
"readMore": "View tutorial",
|
||||||
|
"views": "views",
|
||||||
|
"empty": "No tutorials have been published yet."
|
||||||
|
},
|
||||||
|
"aboutPage": {
|
||||||
|
"title": "About Us",
|
||||||
|
"lead": "FlatRender makes professional video and image creation simple for everyone.",
|
||||||
|
"body": "FlatRender is an online platform for creating videos and images with ready-made templates and smart tools.\n\nOur mission is to let anyone create professional content in minutes — no design or motion-graphics expertise required."
|
||||||
|
},
|
||||||
|
"contactPage": {
|
||||||
|
"title": "Contact Us",
|
||||||
|
"lead": "We'd love to hear from you.",
|
||||||
|
"body": "Reach out for support, partnerships, or any questions.\n\nEmail: support@flatrender.com"
|
||||||
|
},
|
||||||
|
"careersPage": {
|
||||||
|
"title": "Careers",
|
||||||
|
"lead": "Join the FlatRender team.",
|
||||||
|
"body": "We're always looking for talented, passionate people.\n\nTo learn about open positions, send your resume to jobs@flatrender.com."
|
||||||
|
},
|
||||||
|
"privacyPage": {
|
||||||
|
"title": "Privacy Policy",
|
||||||
|
"lead": "How we store and use your information.",
|
||||||
|
"body": "Your privacy matters to us. This page explains how we collect, use and protect your information.\n\nThis is placeholder text and should be completed by an administrator before final publication."
|
||||||
|
},
|
||||||
|
"termsPage": {
|
||||||
|
"title": "Terms of Service",
|
||||||
|
"lead": "The rules for using FlatRender.",
|
||||||
|
"body": "By using FlatRender you agree to the following terms.\n\nThis is placeholder text and should be completed by an administrator before final publication."
|
||||||
|
},
|
||||||
|
"cookiesPage": {
|
||||||
|
"title": "Cookie Policy",
|
||||||
|
"lead": "How we use cookies.",
|
||||||
|
"body": "We use cookies to improve your experience on the site.\n\nThis is placeholder text and should be completed by an administrator before final publication."
|
||||||
|
},
|
||||||
|
"helpPage": {
|
||||||
|
"title": "Help Center",
|
||||||
|
"lead": "Answers to common questions and usage guides.",
|
||||||
|
"body": "Welcome to the FlatRender Help Center.\n\nFor more questions, check the Learn section or contact support."
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "Sign In",
|
"signIn": "Sign In",
|
||||||
"signUp": "Sign Up",
|
"signUp": "Sign Up",
|
||||||
@@ -324,6 +377,8 @@
|
|||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"fonts": "Fonts",
|
"fonts": "Fonts",
|
||||||
"blogs": "Blog",
|
"blogs": "Blog",
|
||||||
|
"learn": "Tutorials",
|
||||||
|
"pages": "Pages",
|
||||||
"slides": "Slides",
|
"slides": "Slides",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"plans": "Plans",
|
"plans": "Plans",
|
||||||
|
|||||||
@@ -187,6 +187,59 @@
|
|||||||
"socialLinkedIn": "لینکدین",
|
"socialLinkedIn": "لینکدین",
|
||||||
"socialYouTube": "یوتیوب"
|
"socialYouTube": "یوتیوب"
|
||||||
},
|
},
|
||||||
|
"blog": {
|
||||||
|
"metaTitle": "وبلاگ فلترندر",
|
||||||
|
"metaDescription": "مقالهها، خبرها و راهنماهای ساخت ویدیو و تصویر.",
|
||||||
|
"pageTitle": "وبلاگ",
|
||||||
|
"pageDescription": "تازهترین مقالهها، خبرها و ایدهها دربارهٔ ساخت ویدیو و تصویر.",
|
||||||
|
"readMore": "ادامهٔ مطلب",
|
||||||
|
"views": "بازدید",
|
||||||
|
"empty": "هنوز مقالهای منتشر نشده است."
|
||||||
|
},
|
||||||
|
"learn": {
|
||||||
|
"metaTitle": "آموزش فلترندر",
|
||||||
|
"metaDescription": "آموزشهای گامبهگام برای ساخت ویدیو و تصویر حرفهای.",
|
||||||
|
"pageTitle": "آموزش",
|
||||||
|
"pageDescription": "آموزشهای گامبهگام و راهنماهای کاربردی برای استفاده از فلترندر.",
|
||||||
|
"readMore": "مشاهدهٔ آموزش",
|
||||||
|
"views": "بازدید",
|
||||||
|
"empty": "هنوز آموزشی منتشر نشده است."
|
||||||
|
},
|
||||||
|
"aboutPage": {
|
||||||
|
"title": "دربارهٔ ما",
|
||||||
|
"lead": "فلترندر، ساخت ویدیو و تصویر حرفهای را برای همه ساده میکند.",
|
||||||
|
"body": "فلترندر یک پلتفرم آنلاین برای ساخت ویدیو و تصویر با کمک قالبهای آماده و ابزارهای هوشمند است.\n\nمأموریت ما این است که هر کسی، بدون نیاز به دانش تخصصی طراحی یا موشنگرافیک، بتواند در چند دقیقه محتوای حرفهای بسازد."
|
||||||
|
},
|
||||||
|
"contactPage": {
|
||||||
|
"title": "تماس با ما",
|
||||||
|
"lead": "خوشحال میشویم از شما بشنویم.",
|
||||||
|
"body": "برای پشتیبانی، همکاری یا هر پرسشی با ما در تماس باشید.\n\nایمیل: support@flatrender.com"
|
||||||
|
},
|
||||||
|
"careersPage": {
|
||||||
|
"title": "فرصتهای شغلی",
|
||||||
|
"lead": "به تیم فلترندر بپیوندید.",
|
||||||
|
"body": "ما همیشه به دنبال افراد بااستعداد و علاقهمند هستیم.\n\nبرای آگاهی از موقعیتهای شغلی، رزومهٔ خود را به jobs@flatrender.com ارسال کنید."
|
||||||
|
},
|
||||||
|
"privacyPage": {
|
||||||
|
"title": "حریم خصوصی",
|
||||||
|
"lead": "نحوهٔ نگهداری و استفادهٔ ما از اطلاعات شما.",
|
||||||
|
"body": "حفظ حریم خصوصی شما برای ما اهمیت دارد. این صفحه نحوهٔ گردآوری، استفاده و محافظت از اطلاعات شما را توضیح میدهد.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
|
||||||
|
},
|
||||||
|
"termsPage": {
|
||||||
|
"title": "شرایط استفاده",
|
||||||
|
"lead": "قوانین استفاده از خدمات فلترندر.",
|
||||||
|
"body": "با استفاده از فلترندر، شرایط زیر را میپذیرید.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
|
||||||
|
},
|
||||||
|
"cookiesPage": {
|
||||||
|
"title": "سیاست کوکی",
|
||||||
|
"lead": "نحوهٔ استفادهٔ ما از کوکیها.",
|
||||||
|
"body": "ما از کوکیها برای بهبود تجربهٔ شما در سایت استفاده میکنیم.\n\nاین متن نمونه است و باید پیش از انتشار نهایی توسط مدیر تکمیل شود."
|
||||||
|
},
|
||||||
|
"helpPage": {
|
||||||
|
"title": "مرکز راهنما",
|
||||||
|
"lead": "پاسخ پرسشهای پرتکرار و راهنمای استفاده.",
|
||||||
|
"body": "به مرکز راهنمای فلترندر خوش آمدید.\n\nبرای پرسشهای بیشتر، بخش آموزش را ببینید یا با پشتیبانی تماس بگیرید."
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "ورود",
|
"signIn": "ورود",
|
||||||
"signUp": "ثبتنام",
|
"signUp": "ثبتنام",
|
||||||
@@ -324,6 +377,8 @@
|
|||||||
"tags": "برچسبها",
|
"tags": "برچسبها",
|
||||||
"fonts": "فونتها",
|
"fonts": "فونتها",
|
||||||
"blogs": "بلاگ",
|
"blogs": "بلاگ",
|
||||||
|
"learn": "آموزشها",
|
||||||
|
"pages": "برگهها",
|
||||||
"slides": "اسلایدها",
|
"slides": "اسلایدها",
|
||||||
"users": "کاربران",
|
"users": "کاربران",
|
||||||
"plans": "پلنها",
|
"plans": "پلنها",
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ public enum JustifyKind { LEFT_JUSTIFY, CENTER_JUSTIFY, RIGHT_JUSTIFY, FULL_JUST
|
|||||||
public enum AiInputType { None, TitleSuggest, BodySuggest, TranslateRtl, TranslateLtr, RemoveBG, UpscaleImage, TTS }
|
public enum AiInputType { None, TitleSuggest, BodySuggest, TranslateRtl, TranslateLtr, RemoveBG, UpscaleImage, TTS }
|
||||||
public enum RepeatSortStrategy { Manual, Alphabetical, Numerical, InsertOrder }
|
public enum RepeatSortStrategy { Manual, Alphabetical, Numerical, InsertOrder }
|
||||||
public enum AttrValueKind { fill, stroke, tracking, dropshadow }
|
public enum AttrValueKind { fill, stroke, tracking, dropshadow }
|
||||||
public enum BlogKind { Blog, Landing }
|
public enum BlogKind { Blog, Landing, Learn, Page }
|
||||||
public enum SlideType { Hero, Promo, Tutorial, Category, Custom }
|
public enum SlideType { Hero, Promo, Tutorial, Category, Custom }
|
||||||
public enum ContainerFavoriteKind { Container }
|
public enum ContainerFavoriteKind { Container }
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Adds the Learn + Page values to the content.blog_kind enum so the Blog entity
|
||||||
|
-- can power the public Learn (tutorials) section and the static CMS pages
|
||||||
|
-- (About, Privacy, Terms, …) in addition to Blog/Landing.
|
||||||
|
--
|
||||||
|
-- Idempotent. ADD VALUE cannot run inside a transaction on older PG, so apply
|
||||||
|
-- each statement on its own (psql autocommit):
|
||||||
|
-- docker exec fr2-postgres sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d flatrender -f -' < 001_blog_kind_learn_page.sql
|
||||||
|
ALTER TYPE content.blog_kind ADD VALUE IF NOT EXISTS 'Learn';
|
||||||
|
ALTER TYPE content.blog_kind ADD VALUE IF NOT EXISTS 'Page';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("aboutPage");
|
||||||
|
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/about" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<CmsRoute slug="about" ns="aboutPage" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ export default async function AdminLayout({
|
|||||||
{ href: "/admin/fonts", label: t("fonts") },
|
{ href: "/admin/fonts", label: t("fonts") },
|
||||||
{ href: "/admin/music", label: t("music") },
|
{ href: "/admin/music", label: t("music") },
|
||||||
{ href: "/admin/blogs", label: t("blogs") },
|
{ href: "/admin/blogs", label: t("blogs") },
|
||||||
|
{ href: "/admin/learn", label: t("learn") },
|
||||||
|
{ href: "/admin/pages", label: t("pages") },
|
||||||
{ href: "/admin/slides", label: t("slides") },
|
{ href: "/admin/slides", label: t("slides") },
|
||||||
{ href: "/admin/home-events", label: t("homeEvents") },
|
{ href: "/admin/home-events", label: t("homeEvents") },
|
||||||
{ href: "/admin/routes", label: t("routes") },
|
{ href: "/admin/routes", label: t("routes") },
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { learnConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={learnConfig} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { pagesConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={pagesConfig} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { ArticleDetailContent } from "@/components/content/article-ui";
|
||||||
|
import { fetchArticle } from "@/lib/content-api";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const a = await fetchArticle(slug);
|
||||||
|
if (!a) return {};
|
||||||
|
return {
|
||||||
|
title: a.metaTitle || a.title,
|
||||||
|
description: a.metaDescription || a.shortDescription || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogDetailPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const a = await fetchArticle(slug);
|
||||||
|
if (!a || !a.isPublished) notFound();
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<ArticleDetailContent section="blog" article={a} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { ArticleListContent } from "@/components/content/article-ui";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
import { fetchArticles } from "@/lib/content-api";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("blog");
|
||||||
|
return createPageMetadata({
|
||||||
|
title: t("metaTitle"),
|
||||||
|
description: t("metaDescription"),
|
||||||
|
path: "/blog",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogPage() {
|
||||||
|
const { items } = await fetchArticles("Blog", { pageSize: 24 });
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<ArticleListContent section="blog" articles={items} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("careersPage");
|
||||||
|
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/careers" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CareersPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<CmsRoute slug="careers" ns="careersPage" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("contactPage");
|
||||||
|
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/contact" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<CmsRoute slug="contact" ns="contactPage" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("cookiesPage");
|
||||||
|
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/cookies" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CookiesPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<CmsRoute slug="cookies" ns="cookiesPage" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("helpPage");
|
||||||
|
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/help" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<CmsRoute slug="help" ns="helpPage" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { ArticleDetailContent } from "@/components/content/article-ui";
|
||||||
|
import { fetchArticle } from "@/lib/content-api";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const a = await fetchArticle(slug);
|
||||||
|
if (!a) return {};
|
||||||
|
return {
|
||||||
|
title: a.metaTitle || a.title,
|
||||||
|
description: a.metaDescription || a.shortDescription || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LearnDetailPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const a = await fetchArticle(slug);
|
||||||
|
if (!a || !a.isPublished) notFound();
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<ArticleDetailContent section="learn" article={a} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { ArticleListContent } from "@/components/content/article-ui";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
import { fetchArticles } from "@/lib/content-api";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("learn");
|
||||||
|
return createPageMetadata({
|
||||||
|
title: t("metaTitle"),
|
||||||
|
description: t("metaDescription"),
|
||||||
|
path: "/learn",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LearnPage() {
|
||||||
|
const { items } = await fetchArticles("Learn", { pageSize: 24 });
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<ArticleListContent section="learn" articles={items} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("privacyPage");
|
||||||
|
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/privacy" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<CmsRoute slug="privacy" ns="privacyPage" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { CmsRoute } from "@/components/content/CmsRoute";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("termsPage");
|
||||||
|
return createPageMetadata({ title: t("title"), description: t("lead"), path: "/terms" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TermsPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<CmsRoute slug="terms" ns="termsPage" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export interface ResourceConfig {
|
|||||||
idKey?: string; // default "id"
|
idKey?: string; // default "id"
|
||||||
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array
|
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array
|
||||||
listQuery?: string; // extra query string appended to the list fetch, e.g. "includeInactive=true"
|
listQuery?: string; // extra query string appended to the list fetch, e.g. "includeInactive=true"
|
||||||
|
fixedValues?: Record<string, string | number | boolean>; // always sent on create/update + seeded into the form (e.g. { kind: "Learn" })
|
||||||
columns: ColumnDef[];
|
columns: ColumnDef[];
|
||||||
fields?: FieldDef[];
|
fields?: FieldDef[];
|
||||||
canCreate?: boolean;
|
canCreate?: boolean;
|
||||||
@@ -108,6 +109,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
|||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
const init: Record<string, unknown> = {};
|
const init: Record<string, unknown> = {};
|
||||||
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
|
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
|
||||||
|
if (config.fixedValues) Object.assign(init, config.fixedValues);
|
||||||
setForm(init);
|
setForm(init);
|
||||||
setSlugTouched(false); // new record → keep syncing slug from name
|
setSlugTouched(false); // new record → keep syncing slug from name
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
@@ -137,7 +139,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
|||||||
const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), {
|
const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), {
|
||||||
method: isEdit ? "PUT" : "POST",
|
method: isEdit ? "PUT" : "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify({ ...config.fixedValues, ...form }),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!res.ok) throw new Error(data?.error ?? "Save failed");
|
if (!res.ok) throw new Error(data?.error ?? "Save failed");
|
||||||
|
|||||||
@@ -171,6 +171,61 @@ export const blogsConfig: ResourceConfig = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const learnConfig: ResourceConfig = {
|
||||||
|
title: "مقالات آموزشی",
|
||||||
|
description: "آموزشها و راهنماهای گامبهگام که در بخش «آموزش» سایت نمایش داده میشوند.",
|
||||||
|
basePath: "blogs",
|
||||||
|
listQuery: "kind=Learn&pageSize=500&page_size=500",
|
||||||
|
listKey: "items",
|
||||||
|
fixedValues: { kind: "Learn" },
|
||||||
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
columns: [
|
||||||
|
{ key: "title", label: "عنوان" },
|
||||||
|
{ key: "slug", label: "اسلاگ" },
|
||||||
|
{ key: "is_published", label: "انتشار", render: (r) => badge(!!r.is_published, "منتشرشده", "پیشنویس") },
|
||||||
|
{ key: "view_count", label: "بازدید" },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "عنوان", required: true },
|
||||||
|
{ key: "slug", label: "اسلاگ (نشانی)", required: true },
|
||||||
|
{ key: "short_description", label: "توضیح کوتاه", type: "textarea" },
|
||||||
|
{ key: "content", label: "محتوا", type: "richtext", required: true },
|
||||||
|
{ key: "cover", label: "تصویر شاخص", type: "image" },
|
||||||
|
{ key: "meta_title", label: "عنوان متا" },
|
||||||
|
{ key: "meta_description", label: "توضیحات متا", type: "textarea" },
|
||||||
|
{ key: "meta_keywords", label: "کلمات کلیدی متا" },
|
||||||
|
{ key: "is_published", label: "منتشرشده", type: "checkbox", defaultValue: true },
|
||||||
|
{ key: "include_in_site_map", label: "نمایش در نقشهٔ سایت", type: "checkbox", defaultValue: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pagesConfig: ResourceConfig = {
|
||||||
|
title: "برگههای سایت",
|
||||||
|
description: "برگههای ثابت مانند «دربارهٔ ما»، «حریم خصوصی» و «شرایط استفاده». اسلاگ باید دقیقاً با نشانی برگه یکی باشد: about، contact، careers، privacy، terms، cookies، help.",
|
||||||
|
basePath: "blogs",
|
||||||
|
listQuery: "kind=Page&pageSize=500&page_size=500",
|
||||||
|
listKey: "items",
|
||||||
|
fixedValues: { kind: "Page", is_published: true, include_in_site_map: true },
|
||||||
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
columns: [
|
||||||
|
{ key: "title", label: "عنوان" },
|
||||||
|
{ key: "slug", label: "اسلاگ (نشانی برگه)" },
|
||||||
|
{ key: "is_published", label: "انتشار", render: (r) => badge(!!r.is_published, "منتشرشده", "پیشنویس") },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "عنوان", required: true },
|
||||||
|
{ key: "slug", label: "اسلاگ (مثلاً about یا privacy)", required: true },
|
||||||
|
{ key: "content", label: "محتوای برگه", type: "richtext", required: true },
|
||||||
|
{ key: "meta_title", label: "عنوان متا" },
|
||||||
|
{ key: "meta_description", label: "توضیحات متا", type: "textarea" },
|
||||||
|
{ key: "is_published", label: "منتشرشده", type: "checkbox", defaultValue: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const slidesConfig: ResourceConfig = {
|
export const slidesConfig: ResourceConfig = {
|
||||||
title: "اسلایدهای صفحه اصلی",
|
title: "اسلایدهای صفحه اصلی",
|
||||||
description: "اسلایدهای هیرو/تبلیغاتی در صفحهٔ اصلی.",
|
description: "اسلایدهای هیرو/تبلیغاتی در صفحهٔ اصلی.",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { articleProse } from "@/components/content/article-ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a static CMS page (About, Privacy, Terms, …). The body is admin-authored
|
||||||
|
* HTML when a matching `kind = Page` row exists; otherwise the caller passes
|
||||||
|
* built-in `fallbackHtml` so the page is never blank.
|
||||||
|
*/
|
||||||
|
export function CmsPageContent({
|
||||||
|
title,
|
||||||
|
html,
|
||||||
|
lead,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
lead?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-14 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="font-heading text-3xl font-extrabold tracking-tight text-neutral-900 sm:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{lead && <p className="mt-4 text-lg leading-relaxed text-neutral-500">{lead}</p>}
|
||||||
|
<div className={`mt-8 ${articleProse}`} dir="auto" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { CmsPageContent } from "@/components/content/CmsPageContent";
|
||||||
|
import { fetchPage } from "@/lib/content-api";
|
||||||
|
|
||||||
|
/** Turn a plain-text fallback (paragraphs split by blank lines) into simple HTML. */
|
||||||
|
function textToHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((p) => `<p>${p.trim().replace(/\n/g, "<br/>")}</p>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server component for a static CMS page. Renders the admin-authored `kind = Page`
|
||||||
|
* row for `slug` when present; otherwise falls back to the localized built-in copy
|
||||||
|
* (title / lead / body) from the `ns` message namespace, so the page is never blank.
|
||||||
|
*/
|
||||||
|
export async function CmsRoute({ slug, ns }: { slug: string; ns: string }) {
|
||||||
|
const t = await getTranslations(ns);
|
||||||
|
const page = await fetchPage(slug);
|
||||||
|
const title = page?.title || t("title");
|
||||||
|
const html = page?.content || textToHtml(t("body"));
|
||||||
|
return <CmsPageContent title={title} html={html} lead={page ? undefined : t("lead")} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import type { Article } from "@/lib/content-api";
|
||||||
|
|
||||||
|
/** RTL-aware prose styling for admin-authored HTML bodies (no typography plugin). */
|
||||||
|
export const articleProse =
|
||||||
|
"max-w-none text-[15px] leading-8 text-neutral-700 " +
|
||||||
|
"[&_h2]:mb-3 [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:text-neutral-900 " +
|
||||||
|
"[&_h3]:mb-2 [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-neutral-900 " +
|
||||||
|
"[&_p]:mb-4 [&_a]:text-blue-600 [&_a]:underline " +
|
||||||
|
"[&_ul]:mb-4 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:mb-1 " +
|
||||||
|
"[&_img]:my-6 [&_img]:rounded-xl [&_blockquote]:my-4 [&_blockquote]:border-s-4 [&_blockquote]:border-blue-200 [&_blockquote]:ps-4 [&_blockquote]:text-neutral-500";
|
||||||
|
|
||||||
|
function formatDate(iso: string | undefined, locale: string): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(iso));
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleCard({ article, section }: { article: Article; section: "blog" | "learn" }) {
|
||||||
|
const locale = useLocale();
|
||||||
|
const t = useTranslations(section);
|
||||||
|
const href = `/${section}/${article.slug}`;
|
||||||
|
const img = article.cover || article.image;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[16/9] w-full overflow-hidden bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||||
|
{img ? (
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt={article.title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-3xl font-bold text-blue-300">
|
||||||
|
{article.title.slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col p-5">
|
||||||
|
<h3 className="font-heading text-lg font-bold leading-snug text-neutral-900 group-hover:text-blue-600">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
{article.shortDescription && (
|
||||||
|
<p className="mt-2 line-clamp-3 text-sm leading-relaxed text-neutral-500">
|
||||||
|
{article.shortDescription}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex items-center justify-between pt-2 text-xs text-neutral-400">
|
||||||
|
<span>{formatDate(article.publishDate || article.createdAt, locale)}</span>
|
||||||
|
<span className="font-medium text-blue-600 group-hover:underline">{t("readMore")} →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleListContent({
|
||||||
|
section,
|
||||||
|
articles,
|
||||||
|
}: {
|
||||||
|
section: "blog" | "learn";
|
||||||
|
articles: Article[];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations(section);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:px-8">
|
||||||
|
<header className="mx-auto max-w-2xl text-center">
|
||||||
|
<h1 className="font-heading text-3xl font-extrabold tracking-tight text-neutral-900 sm:text-4xl">
|
||||||
|
{t("pageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-lg leading-relaxed text-neutral-500">{t("pageDescription")}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{articles.length === 0 ? (
|
||||||
|
<p className="mt-16 text-center text-neutral-400">{t("empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{articles.map((a) => (
|
||||||
|
<ArticleCard key={a.id} article={a} section={section} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleDetailContent({
|
||||||
|
section,
|
||||||
|
article,
|
||||||
|
}: {
|
||||||
|
section: "blog" | "learn";
|
||||||
|
article: Article;
|
||||||
|
}) {
|
||||||
|
const locale = useLocale();
|
||||||
|
const t = useTranslations(section);
|
||||||
|
const img = article.cover || article.image;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-auto max-w-3xl px-4 py-14 sm:px-6 lg:px-8">
|
||||||
|
<Link
|
||||||
|
href={`/${section}`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
← {t("pageTitle")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-6 font-heading text-3xl font-extrabold leading-tight tracking-tight text-neutral-900 sm:text-4xl">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-neutral-400">
|
||||||
|
{article.authorDisplayName && <span>{article.authorDisplayName}</span>}
|
||||||
|
<span>{formatDate(article.publishDate || article.createdAt, locale)}</span>
|
||||||
|
<span>
|
||||||
|
{Number(article.viewCount ?? 0).toLocaleString(locale === "fa" ? "fa-IR" : "en-US")}{" "}
|
||||||
|
{t("views")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{article.shortDescription && (
|
||||||
|
<p className="mt-6 text-lg leading-relaxed text-neutral-600">{article.shortDescription}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{img && (
|
||||||
|
<div className="relative mt-8 aspect-[16/9] w-full overflow-hidden rounded-2xl bg-gray-100">
|
||||||
|
<Image src={img} alt={article.title} fill sizes="(max-width: 768px) 100vw, 768px" className="object-cover" priority />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`mt-8 ${articleProse}`}
|
||||||
|
dir="auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: article.content || "" }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export function Navbar({ user }: { user?: NavUser | null }) {
|
|||||||
|
|
||||||
const learnItems = [
|
const learnItems = [
|
||||||
{ label: t("learnItems.blog"), href: "/blog" },
|
{ label: t("learnItems.blog"), href: "/blog" },
|
||||||
{ label: t("learnItems.tutorials"), href: "/tutorials" },
|
{ label: t("learnItems.tutorials"), href: "/learn" },
|
||||||
{ label: t("learnItems.help"), href: "/help" },
|
{ label: t("learnItems.help"), href: "/help" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Server-side reads for editorial content (Blog, Learn) and CMS pages from the
|
||||||
|
* FlatRender V2 API gateway.
|
||||||
|
*
|
||||||
|
* All three are backed by the content service's `Blog` entity, discriminated by
|
||||||
|
* `kind` (Blog | Learn | Page). Articles (Blog/Learn) are listed + read by slug;
|
||||||
|
* CMS pages (About, Privacy, …) are single rows fetched by their slug.
|
||||||
|
*
|
||||||
|
* Every function returns a safe fallback (empty array / null) when the gateway is
|
||||||
|
* unset or unreachable, so the site still renders standalone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
|
|
||||||
|
export type ArticleKind = "Blog" | "Learn" | "Page";
|
||||||
|
|
||||||
|
export interface Article {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
content: string;
|
||||||
|
image?: string;
|
||||||
|
cover?: string;
|
||||||
|
authorDisplayName?: string;
|
||||||
|
isPublished: boolean;
|
||||||
|
publishDate?: string;
|
||||||
|
viewCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
metaKeywords?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── V2 content-service shapes (snake_case JSON) ───────────────────────────────
|
||||||
|
|
||||||
|
interface V2BlogSummary {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
short_description?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
cover?: string | null;
|
||||||
|
author_display_name?: string | null;
|
||||||
|
is_published: boolean;
|
||||||
|
publish_date?: string | null;
|
||||||
|
view_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface V2BlogDetail extends V2BlogSummary {
|
||||||
|
content: string;
|
||||||
|
meta_title?: string | null;
|
||||||
|
meta_description?: string | null;
|
||||||
|
meta_keywords?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface V2Paged<T> {
|
||||||
|
items: T[];
|
||||||
|
meta?: { page: number; page_size: number; total: number; total_pages: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapArticle(b: V2BlogSummary | V2BlogDetail): Article {
|
||||||
|
const d = b as V2BlogDetail;
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
slug: b.slug,
|
||||||
|
title: b.title,
|
||||||
|
shortDescription: b.short_description ?? undefined,
|
||||||
|
content: d.content ?? "",
|
||||||
|
image: b.image ?? undefined,
|
||||||
|
cover: b.cover ?? undefined,
|
||||||
|
authorDisplayName: b.author_display_name ?? undefined,
|
||||||
|
isPublished: b.is_published,
|
||||||
|
publishDate: b.publish_date ?? undefined,
|
||||||
|
viewCount: b.view_count ?? 0,
|
||||||
|
createdAt: b.created_at,
|
||||||
|
updatedAt: d.updated_at ?? undefined,
|
||||||
|
metaTitle: d.meta_title ?? undefined,
|
||||||
|
metaDescription: d.meta_description ?? undefined,
|
||||||
|
metaKeywords: d.meta_keywords ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeGet<T>(path: string, revalidate = 60): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(gatewayUrl(path), {
|
||||||
|
next: { revalidate },
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleListResult {
|
||||||
|
items: Article[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Published articles of a given kind (Blog or Learn), newest first. */
|
||||||
|
export async function fetchArticles(
|
||||||
|
kind: ArticleKind,
|
||||||
|
opts?: { page?: number; pageSize?: number; search?: string },
|
||||||
|
): Promise<ArticleListResult> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("kind", kind);
|
||||||
|
params.set("isPublished", "true");
|
||||||
|
if (opts?.page) params.set("page", String(opts.page));
|
||||||
|
params.set("pageSize", String(opts?.pageSize ?? 24));
|
||||||
|
if (opts?.search) params.set("search", opts.search);
|
||||||
|
|
||||||
|
const data = await safeGet<V2Paged<V2BlogSummary>>(`/v1/blogs?${params.toString()}`);
|
||||||
|
if (!data?.items) return { items: [], total: 0, page: 1, pageSize: opts?.pageSize ?? 24 };
|
||||||
|
return {
|
||||||
|
items: data.items.map(mapArticle),
|
||||||
|
total: data.meta?.total ?? data.items.length,
|
||||||
|
page: data.meta?.page ?? 1,
|
||||||
|
pageSize: data.meta?.page_size ?? (opts?.pageSize ?? 24),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single article by slug (Blog or Learn). Returns null if not found. */
|
||||||
|
export async function fetchArticle(slug: string): Promise<Article | null> {
|
||||||
|
const data = await safeGet<V2BlogDetail>(`/v1/blogs/${encodeURIComponent(slug)}`);
|
||||||
|
return data ? mapArticle(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CMS page (kind = Page) by its well-known slug, e.g. "about" / "privacy".
|
||||||
|
* Pages are looked up by the same slug endpoint; returns null when unset so the
|
||||||
|
* caller can fall back to built-in default copy.
|
||||||
|
*/
|
||||||
|
export async function fetchPage(slug: string): Promise<Article | null> {
|
||||||
|
const data = await safeGet<V2BlogDetail>(`/v1/blogs/${encodeURIComponent(slug)}`, 300);
|
||||||
|
return data ? mapArticle(data) : null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user