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:
soroush.asadi
2026-06-11 22:43:25 +03:30
parent 6cf8716d7e
commit c92de06c28
25 changed files with 802 additions and 3 deletions
+20
View File
@@ -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>
);
}
+2
View File
@@ -33,6 +33,8 @@ export default async function AdminLayout({
{ href: "/admin/fonts", label: t("fonts") },
{ href: "/admin/music", label: t("music") },
{ 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/home-events", label: t("homeEvents") },
{ href: "/admin/routes", label: t("routes") },
+8
View File
@@ -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} />;
}
+8
View File
@@ -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} />;
}
+32
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}