diff --git a/TODO.md b/TODO.md index 8d4c007..d6e56a8 100644 --- a/TODO.md +++ b/TODO.md @@ -51,11 +51,11 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] Separate Next.js public app in monorepo - [x] [P1] App Router + TypeScript + `src/` structure -- [~] [P1] Public app connected to shared data layer -- [ ] [P1] Localized route structure and middleware rules -- [ ] [P2] Public layout system (header/footer/navigation) -- [ ] [P1] Header banner rendering from CMS-managed content -- [ ] [P2] Basic SEO defaults (metadata, OG, sitemap, robots) +- [x] [P1] Public app connected to shared data layer +- [x] [P1] Localized route structure and middleware rules +- [x] [P2] Public layout system (header/footer/navigation) +- [x] [P1] Header banner rendering from CMS-managed content +- [x] [P2] Basic SEO defaults (metadata, OG, sitemap, robots) ### Testing @@ -204,6 +204,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-10] E2E now runs with deterministic preparation (`test:e2e:prepare`: generate + migrate deploy + seed) before Playwright execution. - [2026-02-10] CI quality workflow `.gitea/workflows/ci.yml` enforces `check`, `typecheck`, `test`, and `test:e2e` against a PostgreSQL service. - [2026-02-10] Admin app now uses a shared shell with permission-aware navigation and dedicated IA routes (`/pages`, `/media`, `/users`, `/commissions`). +- [2026-02-10] Public app now has a shared site layout (`banner/header/footer`), DB-backed header banner config, and SEO defaults (`metadata`, `robots`, `sitemap`). ## How We Use This File diff --git a/apps/web/src/app/[locale]/about/page.tsx b/apps/web/src/app/[locale]/about/page.tsx new file mode 100644 index 0000000..da3118f --- /dev/null +++ b/apps/web/src/app/[locale]/about/page.tsx @@ -0,0 +1,13 @@ +import { getTranslations } from "next-intl/server" + +export default async function AboutPage() { + const t = await getTranslations("About") + + return ( +
+

{t("badge")}

+

{t("title")}

+

{t("description")}

+
+ ) +} diff --git a/apps/web/src/app/[locale]/contact/page.tsx b/apps/web/src/app/[locale]/contact/page.tsx new file mode 100644 index 0000000..8970263 --- /dev/null +++ b/apps/web/src/app/[locale]/contact/page.tsx @@ -0,0 +1,13 @@ +import { getTranslations } from "next-intl/server" + +export default async function ContactPage() { + const t = await getTranslations("Contact") + + return ( +
+

{t("badge")}

+

{t("title")}

+

{t("description")}

+
+ ) +} diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx index d7bf5c4..7addb82 100644 --- a/apps/web/src/app/[locale]/layout.tsx +++ b/apps/web/src/app/[locale]/layout.tsx @@ -1,7 +1,12 @@ +import { getPublicHeaderBanner } from "@cms/db" import { notFound } from "next/navigation" import { hasLocale, NextIntlClientProvider } from "next-intl" +import { getTranslations } from "next-intl/server" import type { ReactNode } from "react" +import { PublicHeaderBanner } from "@/components/public-header-banner" +import { PublicSiteFooter } from "@/components/public-site-footer" +import { PublicSiteHeader } from "@/components/public-site-header" import { routing } from "@/i18n/routing" import { Providers } from "../providers" @@ -12,6 +17,28 @@ type LocaleLayoutProps = { }> } +export async function generateMetadata({ params }: LocaleLayoutProps) { + const { locale } = await params + + if (!hasLocale(routing.locales, locale)) { + return {} + } + + const t = await getTranslations({ + locale, + namespace: "Seo", + }) + + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("title"), + description: t("description"), + }, + } +} + export default async function LocaleLayout({ children, params }: LocaleLayoutProps) { const { locale } = await params @@ -19,9 +46,16 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro notFound() } + const banner = await getPublicHeaderBanner() + return ( - {children} + + + +
{children}
+ +
) } diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index ccc84c3..1b52219 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -2,20 +2,15 @@ import { listPosts } from "@cms/db" import { Button } from "@cms/ui/button" import { getTranslations } from "next-intl/server" -import { LanguageSwitcher } from "@/components/language-switcher" - export const dynamic = "force-dynamic" export default async function HomePage() { const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")]) return ( -
+
-
-

{t("badge")}

- -
+

{t("badge")}

{t("title")}

{t("description")}

@@ -36,6 +31,6 @@ export default async function HomePage() { ))}
-
+ ) } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index b07fc12..f344f8a 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -3,9 +3,30 @@ import type { ReactNode } from "react" import "./globals.css" +const metadataBase = new URL(process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000") + export const metadata: Metadata = { - title: "CMS Web", + metadataBase, + title: { + default: "CMS Web", + template: "%s | CMS Web", + }, description: "Public frontend for the CMS monorepo", + applicationName: "CMS Web", + openGraph: { + type: "website", + siteName: "CMS Web", + title: "CMS Web", + description: "Public frontend for the CMS monorepo", + url: metadataBase, + }, + alternates: { + canonical: "/", + }, + robots: { + index: true, + follow: true, + }, } export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts new file mode 100644 index 0000000..77c45de --- /dev/null +++ b/apps/web/src/app/robots.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from "next" + +const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: `${baseUrl}/sitemap.xml`, + } +} diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts new file mode 100644 index 0000000..56de424 --- /dev/null +++ b/apps/web/src/app/sitemap.ts @@ -0,0 +1,14 @@ +import type { MetadataRoute } from "next" + +const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" + +const publicRoutes = ["/", "/about", "/contact"] + +export default function sitemap(): MetadataRoute.Sitemap { + const now = new Date() + + return publicRoutes.map((route) => ({ + url: `${baseUrl}${route}`, + lastModified: now, + })) +} diff --git a/apps/web/src/components/public-header-banner.tsx b/apps/web/src/components/public-header-banner.tsx new file mode 100644 index 0000000..23e9551 --- /dev/null +++ b/apps/web/src/components/public-header-banner.tsx @@ -0,0 +1,25 @@ +import type { PublicHeaderBanner as PublicHeaderBannerData } from "@cms/db" +import Link from "next/link" + +type PublicHeaderBannerProps = { + banner: PublicHeaderBannerData | null +} + +export function PublicHeaderBanner({ banner }: PublicHeaderBannerProps) { + if (!banner) { + return null + } + + return ( +
+
+

{banner.message}

+ {banner.ctaLabel && banner.ctaHref ? ( + + {banner.ctaLabel} + + ) : null} +
+
+ ) +} diff --git a/apps/web/src/components/public-site-footer.tsx b/apps/web/src/components/public-site-footer.tsx new file mode 100644 index 0000000..aa3185d --- /dev/null +++ b/apps/web/src/components/public-site-footer.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useTranslations } from "next-intl" + +export function PublicSiteFooter() { + const t = useTranslations("Layout") + const year = new Date().getFullYear() + + return ( + + ) +} diff --git a/apps/web/src/components/public-site-header.tsx b/apps/web/src/components/public-site-header.tsx new file mode 100644 index 0000000..cf5703d --- /dev/null +++ b/apps/web/src/components/public-site-header.tsx @@ -0,0 +1,44 @@ +"use client" + +import { useTranslations } from "next-intl" + +import { Link } from "@/i18n/navigation" + +import { LanguageSwitcher } from "./language-switcher" + +export function PublicSiteHeader() { + const t = useTranslations("Layout") + + const navItems = [ + { href: "/", label: t("nav.home") }, + { href: "/about", label: t("nav.about") }, + { href: "/contact", label: t("nav.contact") }, + ] + + return ( +
+
+ + {t("brand")} + + + + + +
+
+ ) +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index f7d4473..f7e84e1 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -15,5 +15,31 @@ "es": "Spanisch", "fr": "Französisch" } + }, + "Layout": { + "brand": "CMS Web", + "nav": { + "home": "Start", + "about": "Über uns", + "contact": "Kontakt" + }, + "footer": { + "copyright": "© {year} CMS Web", + "tagline": "Powered by Next.js, Bun, Prisma und TanStack." + } + }, + "Seo": { + "title": "CMS Web", + "description": "Öffentliches Frontend für das CMS-Monorepo." + }, + "About": { + "badge": "Über uns", + "title": "Über dieses Projekt", + "description": "Diese öffentliche App ist die Frontend-Oberfläche für CMS-gesteuerte Inhalte und kommende dynamische Seiten." + }, + "Contact": { + "badge": "Kontakt", + "title": "Kontakt", + "description": "Kontakt- und Auftragsabläufe werden in den nächsten MVP-Schritten eingeführt." } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 22b2dfc..3f3023a 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -15,5 +15,31 @@ "es": "Spanish", "fr": "French" } + }, + "Layout": { + "brand": "CMS Web", + "nav": { + "home": "Home", + "about": "About", + "contact": "Contact" + }, + "footer": { + "copyright": "© {year} CMS Web", + "tagline": "Powered by Next.js, Bun, Prisma, and TanStack." + } + }, + "Seo": { + "title": "CMS Web", + "description": "Public frontend for the CMS monorepo." + }, + "About": { + "badge": "About", + "title": "About this project", + "description": "This public app is the frontend surface for CMS-driven content and upcoming dynamic pages." + }, + "Contact": { + "badge": "Contact", + "title": "Contact", + "description": "Contact and commission flows will be introduced in upcoming MVP steps." } } diff --git a/apps/web/src/messages/es.json b/apps/web/src/messages/es.json index a157de6..64e49dd 100644 --- a/apps/web/src/messages/es.json +++ b/apps/web/src/messages/es.json @@ -15,5 +15,31 @@ "es": "Español", "fr": "Francés" } + }, + "Layout": { + "brand": "CMS Web", + "nav": { + "home": "Inicio", + "about": "Acerca de", + "contact": "Contacto" + }, + "footer": { + "copyright": "© {year} CMS Web", + "tagline": "Impulsado por Next.js, Bun, Prisma y TanStack." + } + }, + "Seo": { + "title": "CMS Web", + "description": "Frontend público para el monorepo CMS." + }, + "About": { + "badge": "Acerca de", + "title": "Sobre este proyecto", + "description": "Esta app pública es la superficie frontend para contenido gestionado por CMS y próximas páginas dinámicas." + }, + "Contact": { + "badge": "Contacto", + "title": "Contacto", + "description": "Los flujos de contacto y comisiones se incorporarán en los siguientes pasos del MVP." } } diff --git a/apps/web/src/messages/fr.json b/apps/web/src/messages/fr.json index 9817dee..f69a5b0 100644 --- a/apps/web/src/messages/fr.json +++ b/apps/web/src/messages/fr.json @@ -15,5 +15,31 @@ "es": "Espagnol", "fr": "Français" } + }, + "Layout": { + "brand": "CMS Web", + "nav": { + "home": "Accueil", + "about": "À propos", + "contact": "Contact" + }, + "footer": { + "copyright": "© {year} CMS Web", + "tagline": "Propulsé par Next.js, Bun, Prisma et TanStack." + } + }, + "Seo": { + "title": "CMS Web", + "description": "Frontend public pour le monorepo CMS." + }, + "About": { + "badge": "À propos", + "title": "À propos de ce projet", + "description": "Cette application publique est la surface frontend pour le contenu piloté par CMS et les futures pages dynamiques." + }, + "Contact": { + "badge": "Contact", + "title": "Contact", + "description": "Les flux de contact et de commission seront introduits dans les prochaines étapes MVP." } } diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index 3b947a1..5daa7ec 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -12,6 +12,20 @@ async function main() { status: "published", }, }) + + await db.systemSetting.upsert({ + where: { key: "public.header_banner" }, + update: {}, + create: { + key: "public.header_banner", + value: JSON.stringify({ + enabled: true, + message: "New portfolio release is live.", + ctaLabel: "Open latest posts", + ctaHref: "/", + }), + }, + }) } main() diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 4f14908..8cc6c44 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -7,4 +7,9 @@ export { registerPostCrudAuditHook, updatePost, } from "./posts" -export { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "./settings" +export type { PublicHeaderBanner } from "./settings" +export { + getPublicHeaderBanner, + isAdminSelfRegistrationEnabled, + setAdminSelfRegistrationEnabled, +} from "./settings" diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index fb3cd12..5d4ae33 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -1,6 +1,20 @@ import { db } from "./client" const ADMIN_SELF_REGISTRATION_KEY = "admin.self_registration_enabled" +const PUBLIC_HEADER_BANNER_KEY = "public.header_banner" + +type PublicHeaderBannerRecord = { + enabled: boolean + message: string + ctaLabel?: string + ctaHref?: string +} + +export type PublicHeaderBanner = { + message: string + ctaLabel?: string + ctaHref?: string +} function resolveEnvFallback(): boolean { return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true" @@ -18,6 +32,25 @@ function parseStoredBoolean(value: string): boolean | null { return null } +function parsePublicHeaderBanner(value: string): PublicHeaderBannerRecord | null { + try { + const parsed = JSON.parse(value) as Record + + if (typeof parsed.enabled !== "boolean" || typeof parsed.message !== "string") { + return null + } + + return { + enabled: parsed.enabled, + message: parsed.message, + ctaLabel: typeof parsed.ctaLabel === "string" ? parsed.ctaLabel : undefined, + ctaHref: typeof parsed.ctaHref === "string" ? parsed.ctaHref : undefined, + } + } catch { + return null + } +} + export async function isAdminSelfRegistrationEnabled(): Promise { try { const setting = await db.systemSetting.findUnique({ @@ -54,3 +87,30 @@ export async function setAdminSelfRegistrationEnabled(enabled: boolean): Promise }, }) } + +export async function getPublicHeaderBanner(): Promise { + try { + const setting = await db.systemSetting.findUnique({ + where: { key: PUBLIC_HEADER_BANNER_KEY }, + select: { value: true }, + }) + + if (!setting) { + return null + } + + const parsed = parsePublicHeaderBanner(setting.value) + + if (!parsed || !parsed.enabled) { + return null + } + + return { + message: parsed.message, + ctaLabel: parsed.ctaLabel, + ctaHref: parsed.ctaHref, + } + } catch { + return null + } +}