From bca4b55a361c98cc81c99423d15db50c487275a2 Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Thu, 28 May 2026 12:12:14 +0200 Subject: [PATCH 1/5] feat(admin): build /posts/new, /pages/new, /posts/import, /jobs pages (#507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four \`\` destinations in the admin sidebar + list views 404'd: - posts/page.tsx links to /posts/new and /posts/import - pages/page.tsx links to /pages/new - jobs/dlq/page.tsx links to /jobs as a "back" link Build the missing route files. Each is brand-polished (Headline with italic accent, card layout): - /posts/new — Client Component form (title, slug, status). Slug auto-derives from title via exported \`slugify()\` when blank. Submits POST /api/v1/posts with content_blocks: []. On success navigates to /posts/{id} (the existing editor takes over). Surfaces ApiError inline. Headline: "Write your *next* post." - /pages/new — Sibling shape with post_type: 'page' and a slash- prefixed slug (/about-us). Redirects to /pages/{id}. - /posts/import — Server-rendered explainer card linking to /migrate (where the WordPress importer lives). Lists the three supported sources (WXR, WP REST, ACF JSON). - /jobs — Static card grid, one card per queue from the canonical KNOWN_QUEUES set (critical, default, webhooks, media, search, reports, low — same list the DLQ chip filter reads). Each card links to /jobs/dlq?queue={name}. 22 new tests across the four pages: slugify unit cases, render, headline, submit POST body + redirect, error branch. Closes #507. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tayeb Mokni --- .../app/(authenticated)/jobs/page.test.tsx | 43 ++++ .../src/app/(authenticated)/jobs/page.tsx | 119 +++++++++ .../(authenticated)/pages/new/page.test.tsx | 90 +++++++ .../app/(authenticated)/pages/new/page.tsx | 220 +++++++++++++++++ .../posts/import/page.test.tsx | 30 +++ .../app/(authenticated)/posts/import/page.tsx | 101 ++++++++ .../(authenticated)/posts/new/page.test.tsx | 120 +++++++++ .../app/(authenticated)/posts/new/page.tsx | 230 ++++++++++++++++++ 8 files changed, 953 insertions(+) create mode 100644 apps/admin/src/app/(authenticated)/jobs/page.test.tsx create mode 100644 apps/admin/src/app/(authenticated)/jobs/page.tsx create mode 100644 apps/admin/src/app/(authenticated)/pages/new/page.test.tsx create mode 100644 apps/admin/src/app/(authenticated)/pages/new/page.tsx create mode 100644 apps/admin/src/app/(authenticated)/posts/import/page.test.tsx create mode 100644 apps/admin/src/app/(authenticated)/posts/import/page.tsx create mode 100644 apps/admin/src/app/(authenticated)/posts/new/page.test.tsx create mode 100644 apps/admin/src/app/(authenticated)/posts/new/page.tsx diff --git a/apps/admin/src/app/(authenticated)/jobs/page.test.tsx b/apps/admin/src/app/(authenticated)/jobs/page.test.tsx new file mode 100644 index 0000000..81424d2 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/jobs/page.test.tsx @@ -0,0 +1,43 @@ +/** + * /jobs smoke tests. Pure server-rendered grid — the assertions + * confirm one card per known queue and that each card links to the + * filtered DLQ. Issue #507. + */ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import JobsPage from './page'; +import { KNOWN_QUEUES } from './dlq/types'; + +describe('JobsPage', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('jobs-page')).toBeInTheDocument(); + }); + + it('renders the italic-accent headline ("Background jobs.")', () => { + render(); + const h1 = screen.getByRole('heading', { level: 1 }); + expect(h1.textContent).toMatch(/Background\s+jobs\./); + expect(h1.querySelector('em')?.textContent).toBe('jobs'); + }); + + it('renders one card per known queue', () => { + render(); + const list = screen.getByRole('list', { name: /Queues/i }); + const items = list.querySelectorAll('li'); + expect(items.length).toBe(KNOWN_QUEUES.length); + }); + + it('cards link to /jobs/dlq filtered by queue', () => { + render(); + for (const queue of KNOWN_QUEUES) { + const card = screen.getByTestId(`jobs-queue-card-${queue}`); + // Next's serialises `{pathname, query}` into the rendered + // href, so we just look for the encoded query param. + expect(card.getAttribute('href')).toMatch( + new RegExp(`/jobs/dlq\\?queue=${queue}`), + ); + } + }); +}); diff --git a/apps/admin/src/app/(authenticated)/jobs/page.tsx b/apps/admin/src/app/(authenticated)/jobs/page.tsx new file mode 100644 index 0000000..ece92d1 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/jobs/page.tsx @@ -0,0 +1,119 @@ +/** + * Jobs — queue landing page. + * + * The DLQ subsurface (/jobs/dlq) links here via a "Back to jobs" + * affordance, but the parent route had no `page.tsx` and 404'd + * (issue #507). This file fills the gap with a static card grid: one + * card per known queue, each routing to the DLQ filtered by that + * queue. + * + * No data fetching today. The queue topology is loaded from the same + * `KNOWN_QUEUES` constant the DLQ chip filter reads — that list is the + * canonical chassis queue set (docs/05-admin-api.md §4.3). Live + * queue-depth / failed-count instrumentation lands when the + * `/api/v1/admin/jobs/stats` endpoint ships; for now the cards exist so + * the surface stops looking broken and operators have a discoverable + * path into each queue's DLQ. + * + * Brand: italic accent on the noun ("Background *jobs*."), card grid + * on paper-2, queue-tone matching the DLQ chips so the cross-link feels + * cohesive. + */ +import type { ReactElement } from 'react'; +import Link from 'next/link'; +import { ArrowRight } from 'lucide-react'; + +import { Headline } from '@/components/ui/headline'; +import { Badge } from '@/components/ui/badge'; +import { KNOWN_QUEUES } from './dlq/types'; + +/** + * Short, human description for each known queue. Keeps the landing + * page readable instead of just listing nouns — operators can pick + * the right queue without cross-referencing the chassis docs. + * + * Unknown queues (e.g. plugin-defined) render with a generic blurb + * via `fallbackDescription`; the URL is the queue name so the DLQ + * still works for them. + */ +const QUEUE_DESCRIPTIONS: Readonly> = { + critical: 'High-priority work — auth flows, billing webhooks, anything that blocks a user.', + default: 'Catch-all queue. If a job didn’t pick a queue, it lands here.', + webhooks: 'Outgoing webhook deliveries and the retry budget that comes with them.', + media: 'Image processing, thumbnail derivatives, large-file moves.', + search: 'Search index re-builds and incremental document updates.', + reports: 'Long-running analytics rollups, exports, scheduled summaries.', + low: 'Background cleanup — orphan sweeps, archive compaction, telemetry rolls.', +}; + +const fallbackDescription = + 'Background tasks routed onto this queue. Open the DLQ to inspect anything that failed.'; + +/** + * Surface tone for the queue badge. Mirrors `queueTone()` in the DLQ + * client so a "critical" chip looks the same here as it does on the + * DLQ list — the cross-link is a continuation of the same view. + */ +function queueTone( + queue: string, +): 'emerald' | 'lavender' | 'outline' | 'default' { + if (queue === 'critical') return 'emerald'; + if (queue === 'webhooks' || queue === 'important') return 'lavender'; + if (queue === 'low') return 'outline'; + return 'default'; +} + +export default function JobsPage(): ReactElement { + return ( +
+ {/* Page head — instrument-panel feel, matches the DLQ sibling. */} +
+ + Operations + + + Background jobs. + +

+ Every async task runs on one of the chassis queues. Pick a queue + to inspect its dead-letter contents and replay or discard + failures. +

+
+ +
    + {KNOWN_QUEUES.map((queue) => ( +
  • + +
    + + {queue} + +
    +

    + {QUEUE_DESCRIPTIONS[queue] ?? fallbackDescription} +

    + + Open dead-letter queue + + +
  • + ))} +
+
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/pages/new/page.test.tsx b/apps/admin/src/app/(authenticated)/pages/new/page.test.tsx new file mode 100644 index 0000000..b242314 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/pages/new/page.test.tsx @@ -0,0 +1,90 @@ +/** + * /pages/new smoke tests. Mirrors the posts/new shape — issue #507. + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +const pushMock = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock, replace: vi.fn(), prefetch: vi.fn() }), +})); + +const apiPostMock = vi.fn(); +vi.mock('@/lib/api-client', async () => { + const actual = + await vi.importActual( + '@/lib/api-client', + ); + return { + ...actual, + api: { ...actual.api, post: (...args: unknown[]) => apiPostMock(...args) }, + }; +}); + +import NewPagePage, { slugifyPage } from './page'; +import { ApiError } from '@/lib/api-client'; + +beforeEach(() => { + pushMock.mockReset(); + apiPostMock.mockReset(); +}); + +describe('slugifyPage', () => { + it('emits a leading-slash slug', () => { + expect(slugifyPage('About Us')).toBe('/about-us'); + }); + it('returns empty for an alphanumeric-less input', () => { + expect(slugifyPage(' ')).toBe(''); + }); +}); + +describe('NewPagePage', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('new-page-page')).toBeInTheDocument(); + }); + + it('renders the italic-accent headline ("Create a new page.")', () => { + render(); + const h1 = screen.getByRole('heading', { level: 1 }); + expect(h1.textContent).toMatch(/Create\s+a\s+new\s+page\./); + expect(h1.querySelector('em')?.textContent).toBe('page'); + }); + + it('POSTs to /api/v1/posts with post_type:"page" and redirects on success', async () => { + apiPostMock.mockResolvedValueOnce({ id: 'about' }); + render(); + + fireEvent.change(screen.getByLabelText(/Title/i), { + target: { value: 'About Us' }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('new-page-submit')); + }); + + expect(apiPostMock).toHaveBeenCalledWith('/api/v1/posts', { + title: 'About Us', + slug: '/about-us', + status: 'draft', + post_type: 'page', + content_blocks: [], + }); + expect(pushMock).toHaveBeenCalledWith('/pages/about'); + }); + + it('renders an inline error when the API rejects the create', async () => { + apiPostMock.mockRejectedValueOnce( + new ApiError(422, 'Unprocessable Entity', null), + ); + render(); + fireEvent.change(screen.getByLabelText(/Title/i), { + target: { value: 'Bad' }, + }); + await act(async () => { + fireEvent.click(screen.getByTestId('new-page-submit')); + }); + expect(screen.getByTestId('new-page-error')).toHaveTextContent(/HTTP 422/); + expect(pushMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/app/(authenticated)/pages/new/page.tsx b/apps/admin/src/app/(authenticated)/pages/new/page.tsx new file mode 100644 index 0000000..b5e9e14 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/pages/new/page.tsx @@ -0,0 +1,220 @@ +/** + * New page — admin form. + * + * Sister of `/posts/new`. Pages share the post-type infrastructure + * (docs/05-admin-api.md §3.1), so we POST to `/api/v1/posts` with + * `post_type: 'page'` to land in the right bucket. Once the row exists + * the operator is routed to `/pages/{id}` where the existing metadata + * editor takes over. + * + * The slug field is mandatory for pages because the URL is the page — + * a draft post can ship without a finalised slug, but a page can't be + * linked into a navigation menu until its URL is fixed. We still + * auto-derive from the title if the field is blank, but the help text + * makes the URL-as-identity expectation clear. + * + * Brand: "Create a new *page*." — italic accent on the noun, matches + * the moodboard pattern from the pages list and the `/posts/new` + * sibling. Card form on paper-2. + */ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + useState, + type FormEvent, + type ReactElement, +} from 'react'; +import { ChevronLeft, Loader2, Plus } from 'lucide-react'; + +import { ApiError, api } from '@/lib/api-client'; +import { Button } from '@/components/ui/button'; +import { Headline } from '@/components/ui/headline'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type PageStatus = 'draft' | 'publish' | 'private'; + +interface CreatePageBody { + title: string; + slug: string; + status: PageStatus; + post_type: 'page'; + content_blocks: never[]; +} + +interface CreatePageResponse { + id: string; +} + +/** + * Pages live at flat URLs (`/about`, `/contact`) so we normalise the + * slug into the leading-slash form callers expect. The server will + * normalise again, but doing it here keeps the placeholder honest. + */ +export function slugifyPage(input: string): string { + const base = input + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return base ? `/${base}` : ''; +} + +export default function NewPagePage(): ReactElement { + const router = useRouter(); + const [title, setTitle] = useState(''); + const [slug, setSlug] = useState(''); + const [status, setStatus] = useState('draft'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async (event: FormEvent): Promise => { + event.preventDefault(); + setError(null); + + const finalTitle = title.trim(); + if (!finalTitle) { + setError('Give the page a title — it doubles as the default

.'); + return; + } + + const finalSlug = slug.trim() || slugifyPage(finalTitle); + + setSubmitting(true); + try { + const body: CreatePageBody = { + title: finalTitle, + slug: finalSlug, + status, + post_type: 'page', + content_blocks: [], + }; + const created = await api.post('/api/v1/posts', body); + router.push(`/pages/${encodeURIComponent(created.id)}`); + } catch (err) { + if (err instanceof ApiError) { + setError( + `Couldn't create the page (HTTP ${err.status} ${err.statusText}).`, + ); + } else { + setError(err instanceof Error ? err.message : "Couldn't create the page."); + } + setSubmitting(false); + } + }; + + return ( +
+
+ +
+ +
{ + void onSubmit(event); + }} + className="rounded-lg border border-border bg-paper-2 p-6 shadow-xs" + noValidate + > +
+ + setTitle(e.target.value)} + required + autoFocus + placeholder="What's this page for?" + className="w-full bg-transparent font-display text-3xl font-bold leading-tight tracking-tight text-ink outline-none placeholder:text-fg-faint focus:outline-none" + /> +
+ +
+ + setSlug(e.target.value)} + placeholder={title ? slugifyPage(title) : '/about'} + className="font-mono" + /> +

+ Leave blank to derive from the title. Pages live at flat URLs — + /about rather than{' '} + /blog/about. +

+
+ +
+ + +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/posts/import/page.test.tsx b/apps/admin/src/app/(authenticated)/posts/import/page.test.tsx new file mode 100644 index 0000000..7e8b2f7 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/posts/import/page.test.tsx @@ -0,0 +1,30 @@ +/** + * /posts/import smoke tests. Pure server-rendered explainer — the + * assertions confirm the route renders and that the CTA points at the + * migration wizard so the original click-target intent (bulk import) + * is preserved. Issue #507. + */ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import ImportPostsPage from './page'; + +describe('ImportPostsPage', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('import-posts-page')).toBeInTheDocument(); + }); + + it('renders the italic-accent headline ("Import posts.")', () => { + render(); + const h1 = screen.getByRole('heading', { level: 1 }); + expect(h1.textContent).toMatch(/Import\s+posts\./); + expect(h1.querySelector('em')?.textContent).toBe('posts'); + }); + + it('routes the primary CTA to the migration wizard', () => { + render(); + const cta = screen.getByTestId('import-posts-cta'); + expect(cta).toHaveAttribute('href', '/migrate'); + }); +}); diff --git a/apps/admin/src/app/(authenticated)/posts/import/page.tsx b/apps/admin/src/app/(authenticated)/posts/import/page.tsx new file mode 100644 index 0000000..d0529b9 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/posts/import/page.tsx @@ -0,0 +1,101 @@ +/** + * Import posts — explainer / forward to the migration wizard. + * + * The "Import" button on the posts list (`/posts` toolbar) used to + * 404 (issue #507). Bulk import for GoNext lives behind the dedicated + * migration wizard at `/migrate` — that surface handles WordPress + * exports (WXR / live REST URL / ACF JSON) and is the right entry + * point for any meaningful bulk-load. + * + * This page therefore does not implement its own import flow; it + * renders an explainer card that hands off to `/migrate`. The link is + * the primary CTA so a single click gets the operator where they + * already wanted to go, while the surrounding copy spells out which + * sources are supported (so they know to expect a WordPress flow). + * + * If a future "drop a CSV here" lightweight importer ships separately + * from the migration wizard, this is the file it'll grow into. + * + * Brand: card + italic-accent headline matching the moodboard. The + * page-head stays on the same template as siblings so the IA reads + * predictably. + */ +import Link from 'next/link'; +import type { ReactElement } from 'react'; +import { ArrowRight, ChevronLeft, Upload } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Headline } from '@/components/ui/headline'; + +export const metadata = { + title: 'Import posts · GoNext admin', +}; + +export default function ImportPostsPage(): ReactElement { + return ( +
+
+ +
+ +
+
+ +
+ + Bulk import goes through Migration. + +

+ The migration surface walks you through source → options → + dry-run preview → commit → report. Use it whether you're + moving from WordPress or just importing a handful of posts from + an export file. Everything lands as proper{' '} + post rows that the + regular editor can pick up. +

+
    +
  • WordPress WXR (XML) upload
  • +
  • Live WordPress REST URL
  • +
  • ACF JSON export
  • +
+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/posts/new/page.test.tsx b/apps/admin/src/app/(authenticated)/posts/new/page.test.tsx new file mode 100644 index 0000000..cb9126e --- /dev/null +++ b/apps/admin/src/app/(authenticated)/posts/new/page.test.tsx @@ -0,0 +1,120 @@ +/** + * /posts/new smoke tests. + * + * The full submit flow is covered indirectly via the api-client (mocked + * with `vi.mock`); here we keep the assertions on the brand surface + + * happy-path navigation + error-render branch. Issue #507. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +// next/navigation hooks live in a module that throws when imported +// outside a Next runtime; stub the surface this page uses. +const pushMock = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock, replace: vi.fn(), prefetch: vi.fn() }), +})); + +// Mock the api-client module so the test doesn't hit the network. We +// rebind the resolve / reject value per-test. +const apiPostMock = vi.fn(); +vi.mock('@/lib/api-client', async () => { + const actual = + await vi.importActual( + '@/lib/api-client', + ); + return { + ...actual, + api: { ...actual.api, post: (...args: unknown[]) => apiPostMock(...args) }, + }; +}); + +import NewPostPage, { slugify } from './page'; +import { ApiError } from '@/lib/api-client'; + +beforeEach(() => { + pushMock.mockReset(); + apiPostMock.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('slugify', () => { + it('lowercases and replaces non-alphanumerics with hyphens', () => { + expect(slugify('Hello, World!')).toBe('hello-world'); + }); + it('collapses runs and trims edge hyphens', () => { + expect(slugify(' -- Foo // Bar --')).toBe('foo-bar'); + }); + it('returns an empty string for an input with no alphanumerics', () => { + expect(slugify('!!!')).toBe(''); + }); +}); + +describe('NewPostPage — render', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('new-post-page')).toBeInTheDocument(); + }); + + it('renders the italic-accent headline ("Write your next post.")', () => { + render(); + const h1 = screen.getByRole('heading', { level: 1 }); + expect(h1.textContent).toMatch(/Write\s+your\s+next\s+post\./); + expect(h1.querySelector('em')?.textContent).toBe('next'); + }); + + it('shows the create-draft submit button', () => { + render(); + expect(screen.getByTestId('new-post-submit')).toHaveTextContent(/Create draft/); + }); +}); + +describe('NewPostPage — submit', () => { + it('blocks submit when title is empty and surfaces an error', () => { + render(); + fireEvent.click(screen.getByTestId('new-post-submit')); + expect(screen.getByTestId('new-post-error')).toHaveTextContent(/title/i); + expect(apiPostMock).not.toHaveBeenCalled(); + }); + + it('POSTs the title/slug/status and routes to /posts/{id} on success', async () => { + apiPostMock.mockResolvedValueOnce({ id: 'abc-123' }); + render(); + + fireEvent.change(screen.getByLabelText(/Title/i), { + target: { value: 'Hello, World!' }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('new-post-submit')); + }); + + expect(apiPostMock).toHaveBeenCalledWith('/api/v1/posts', { + title: 'Hello, World!', + slug: 'hello-world', + status: 'draft', + content_blocks: [], + }); + expect(pushMock).toHaveBeenCalledWith('/posts/abc-123'); + }); + + it('renders the ApiError message on failure', async () => { + apiPostMock.mockRejectedValueOnce( + new ApiError(409, 'Conflict', { message: 'slug taken' }), + ); + render(); + fireEvent.change(screen.getByLabelText(/Title/i), { + target: { value: 'Repeat' }, + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('new-post-submit')); + }); + + expect(screen.getByTestId('new-post-error')).toHaveTextContent(/HTTP 409/); + expect(pushMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/app/(authenticated)/posts/new/page.tsx b/apps/admin/src/app/(authenticated)/posts/new/page.tsx new file mode 100644 index 0000000..5585f16 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/posts/new/page.tsx @@ -0,0 +1,230 @@ +/** + * New post — admin form. + * + * Thin Client Component that POSTs a minimal draft (title, slug, status, + * empty `content_blocks`) to `/api/v1/posts` and then routes the operator + * into the existing post editor at `/posts/{new-id}`. The editor takes + * over for body composition; this page only exists so the "New post" CTA + * on the list view stops 404'ing (issue #507). + * + * Slug derivation + * =============== + * Operators rarely care about the slug at creation time — they want to + * start writing. When the slug input is blank we auto-derive it from the + * title (lowercased, alphanumerics + hyphens, collapsed repeats). The + * server is still authoritative; this is a convenience so the first + * write succeeds. + * + * Brand: the page-head follows the moodboard pattern — display-type + * headline with the italic-serif accent on `*next*` ("Write your *next* + * post."), card layout for the form, emerald primary on Create. + */ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + useState, + type FormEvent, + type ReactElement, +} from 'react'; +import { ChevronLeft, Loader2, Plus } from 'lucide-react'; + +import { ApiError, api } from '@/lib/api-client'; +import { Button } from '@/components/ui/button'; +import { Headline } from '@/components/ui/headline'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type PostStatus = 'draft' | 'publish' | 'private' | 'future'; + +interface CreatePostBody { + title: string; + slug: string; + status: PostStatus; + content_blocks: never[]; +} + +interface CreatePostResponse { + id: string; +} + +/** + * Derive a URL-safe slug from a free-form title. Mirrors the server's + * own normalisation closely enough that the value won't shock-change + * after the round-trip: + * + * "Hello, World!" → "hello-world" + * + * Returns an empty string if the input contains no alphanumerics — the + * server will then reject the body and surface a helpful error. + */ +export function slugify(input: string): string { + return input + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export default function NewPostPage(): ReactElement { + const router = useRouter(); + const [title, setTitle] = useState(''); + const [slug, setSlug] = useState(''); + const [status, setStatus] = useState('draft'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async (event: FormEvent): Promise => { + event.preventDefault(); + setError(null); + + const finalTitle = title.trim(); + if (!finalTitle) { + setError('Give the post a title — even a working one helps you find it later.'); + return; + } + + const finalSlug = slug.trim() || slugify(finalTitle); + + setSubmitting(true); + try { + const body: CreatePostBody = { + title: finalTitle, + slug: finalSlug, + status, + content_blocks: [], + }; + const created = await api.post('/api/v1/posts', body); + // The editor route reads the id from the URL and pulls metadata + // from the API on mount — no client-side cache to seed here. + router.push(`/posts/${encodeURIComponent(created.id)}`); + } catch (err) { + if (err instanceof ApiError) { + setError( + `Couldn't create the post (HTTP ${err.status} ${err.statusText}).`, + ); + } else { + setError(err instanceof Error ? err.message : "Couldn't create the post."); + } + setSubmitting(false); + } + }; + + return ( +
+
+ +
+ +
{ + void onSubmit(event); + }} + className="rounded-lg border border-border bg-paper-2 p-6 shadow-xs" + noValidate + > +
+ + setTitle(e.target.value)} + required + autoFocus + placeholder="What's this post about?" + className="w-full bg-transparent font-display text-3xl font-bold leading-tight tracking-tight text-ink outline-none placeholder:text-fg-faint focus:outline-none" + /> +
+ +
+ +
+ + /blog/ + + setSlug(e.target.value)} + placeholder={title ? slugify(title) : 'auto-generated-from-title'} + className="border-0 bg-transparent font-mono focus-visible:ring-0 focus-visible:shadow-none" + /> +
+

+ Leave blank to derive from the title. +

+
+ +
+ + +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ); +} From 1ac16d3d5121b64b40c83a4f6d4fda4279eb6174 Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Thu, 28 May 2026 12:12:34 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(api+web):=20wire=20admin=20menus=20?= =?UTF-8?q?=E2=86=92=20public=20site=20navigation=20(#509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marketing site's nav + footer columns were hardcoded const arrays (\`NAV_LINKS\`, footer-product, footer-resources, etc.). Admin → Appearance → Menus persisted menus into the menu store but nothing read them. ### API: new public-read endpoint \`apps/api/internal/public/menus/\` (new): - \`GET /api/v1/menus\` lists all configured menus - \`GET /api/v1/menus/by-location/{location}\` returns items for the menu whose slug matches {location} - Anonymous-readable (no policy gate) — anonymous visitors render the nav on the marketing landing - Empty/missing menu → 200 \`{"items": []}\` (never 404 — graceful) - Computes \`external\` flag server-side from URL shape (scheme, scheme-relative \`//\`, mailto:/tel:) 7 tests cover the shape contract, missing menu, empty menu, list endpoint, anonymous access, and the isExternalURL matrix. ### Web: consumer apps/web/src/lib/api.ts now exports \`MenuItem\` + \`fetchMenu(location)\`. Returns \`[]\` on any fetch error — graceful degrade, never throws. Nav.tsx + Footer.tsx replace their NAV_LINKS / footer-* arrays with \`await fetchMenu(...)\` calls (parallel-fetched with site-name via Promise.all). Both fall back to a sensible default array when the menu is empty, so a fresh install renders a usable nav rather than blank. External links render as plain \`\`; internal as next/link. ### Wired main.go now Mounts publicmenus next to the admin menu mount, reusing the existing menuStore so admin edits land on the public read path immediately. Closes #509. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tayeb Mokni --- apps/api/cmd/server/main.go | 18 ++ apps/api/internal/public/menus/handler.go | 180 +++++++++++++ .../api/internal/public/menus/handler_test.go | 236 ++++++++++++++++++ .../src/components/marketing/Footer.test.tsx | 42 +++- apps/web/src/components/marketing/Footer.tsx | 114 +++++---- .../web/src/components/marketing/Nav.test.tsx | 38 ++- apps/web/src/components/marketing/Nav.tsx | 86 +++++-- apps/web/src/lib/api.ts | 159 ++++++++++++ 8 files changed, 794 insertions(+), 79 deletions(-) create mode 100644 apps/api/internal/public/menus/handler.go create mode 100644 apps/api/internal/public/menus/handler_test.go diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index d7a121e..f353ea6 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -43,6 +43,7 @@ import ( adminmarketplace "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/marketplace" adminmedia "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/media" adminmenus "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/menus" + publicmenus "github.com/Singleton-Solution/GoNext/apps/api/internal/public/menus" adminpluginpages "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/pluginpages" adminposts "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/posts" adminsettings "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/settings" @@ -1453,6 +1454,23 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se ) } + // Public menus surface (/api/v1/menus). Read-only, no policy gate — + // the marketing landing renders for anonymous visitors and reads + // its nav + footer columns from this endpoint. Reuses the same + // store the admin write surface mutates, so the read path always + // reflects the latest operator edits without a separate cache. See + // issue #509. + if err := publicmenus.Mount(mux, "/api/v1/menus", publicmenus.Deps{ + Store: menusStore, + Logger: logger, + }); err != nil { + logger.Warn("public/menus: failed to mount", slog.Any("err", err)) + } else { + logger.Info("public/menus: routes mounted", + slog.String("base", "/api/v1/menus"), + ) + } + // Admin status surface (/api/v1/admin/status). Aggregates DB, // Redis, queue, plugin, and disk state for the operator dashboard. // Each source is independently nil-tolerant inside the handler; diff --git a/apps/api/internal/public/menus/handler.go b/apps/api/internal/public/menus/handler.go new file mode 100644 index 0000000..e31107d --- /dev/null +++ b/apps/api/internal/public/menus/handler.go @@ -0,0 +1,180 @@ +// Package menus is the public, read-only REST surface for navigation +// menus — issue #509. +// +// Routes (mounted under base, typically /api/v1/menus): +// +// GET {base} — list every configured menu +// (sitemap generators, rare). +// GET {base}/by-location/{location} — items for the menu whose slug +// matches {location}. +// +// Why a separate package from internal/admin/menus: the admin surface +// is mutating and gated by manage_themes. The public reader is the +// surface the unauthenticated marketing landing hits to paint its nav +// and footer columns — it must answer without a session. +// +// Empty/missing menu returns `{"items": []}` with 200, never 404. The +// rationale is that the public site falls back to a hardcoded default +// list when no items come back; treating "no menu configured" as a +// hard error would force every caller to special-case 404 to avoid +// rendering an empty nav. +package menus + +import ( + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router" + "github.com/Singleton-Solution/GoNext/packages/go/menus" +) + +// Deps is the dependency bag for [Mount]. +type Deps struct { + // Store is the read side of the menus persistence layer. The + // public reader only calls ListMenus and GetWithItemsBySlug; any + // implementation satisfying [menus.Store] works. + Store menus.Store + // Logger is optional; defaults to slog.Default(). + Logger *slog.Logger +} + +func (d Deps) validate() error { + if d.Store == nil { + return errors.New("public/menus: Deps.Store is required") + } + return nil +} + +type handlers struct { + store menus.Store + logger *slog.Logger +} + +// Mount wires the public menu routes onto mux. No policy gate: these +// endpoints serve unauthenticated visitors hitting the marketing +// landing. +func Mount(mux *http.ServeMux, base string, deps Deps) error { + if err := deps.validate(); err != nil { + return err + } + if deps.Logger == nil { + deps.Logger = slog.Default() + } + h := &handlers{store: deps.Store, logger: deps.Logger} + base = strings.TrimRight(base, "/") + mux.Handle("GET "+base, http.HandlerFunc(h.list)) + mux.Handle("GET "+base+"/by-location/{location}", http.HandlerFunc(h.byLocation)) + return nil +} + +// menuSummary is the trimmed shape returned by list — slug + name only. +// The public surface deliberately drops timestamps and attrs blobs to +// keep the payload tight; sitemap generators only need the slug. +type menuSummary struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +// publicItem is the projection emitted to the public site. We don't +// surface the internal path ordering token, the menu_id, or the +// CMS-side object_type/object_id linkage — none of those are useful to +// the renderer, and trimming them keeps the surface area small. +type publicItem struct { + Label string `json:"label"` + Href string `json:"href"` + External bool `json:"external"` +} + +// list answers GET /api/v1/menus with the configured menus. Empty +// store returns `{"menus": []}` with 200 — never 404, the public +// surface never fails closed. +func (h *handlers) list(w http.ResponseWriter, r *http.Request) { + all, err := h.store.ListMenus(r.Context()) + if err != nil { + h.logger.ErrorContext(r.Context(), "public/menus: list", slog.Any("err", err)) + // A store error is a server problem, not a client one — but + // we keep the public surface forgiving: an empty list is a + // safer signal to the renderer than a 500 that breaks the + // page. + router.WriteJSON(w, http.StatusOK, map[string]any{"menus": []menuSummary{}}) + return + } + out := make([]menuSummary, 0, len(all)) + for _, m := range all { + out = append(out, menuSummary{Slug: m.Slug, Name: m.Name}) + } + router.WriteJSON(w, http.StatusOK, map[string]any{"menus": out}) +} + +// byLocation answers GET /api/v1/menus/by-location/{location} with the +// items belonging to the menu whose slug matches {location}. The +// "location" name reflects how the public site uses these — "primary" +// for top nav, "footer-product" for a footer column, etc. — but +// underneath we just look up by slug. +// +// Missing menu, empty menu, and store error all return `{"items": []}` +// with 200; see the package doc for why. +func (h *handlers) byLocation(w http.ResponseWriter, r *http.Request) { + location := strings.TrimSpace(r.PathValue("location")) + if location == "" { + // Empty path segment is a 200 with empty items, matching the + // "never 404" contract. + router.WriteJSON(w, http.StatusOK, map[string]any{"items": []publicItem{}}) + return + } + bundle, err := h.store.GetWithItemsBySlug(r.Context(), location) + if err != nil { + if !errors.Is(err, menus.ErrNotFound) { + // Not-found is the expected path on a fresh install; only + // log unexpected store failures. + h.logger.ErrorContext(r.Context(), "public/menus: byLocation", + slog.String("location", location), + slog.Any("err", err), + ) + } + router.WriteJSON(w, http.StatusOK, map[string]any{"items": []publicItem{}}) + return + } + out := make([]publicItem, 0, len(bundle.Items)) + for _, mi := range bundle.Items { + out = append(out, publicItem{ + Label: mi.Label, + Href: mi.URL, + External: isExternalURL(mi.URL), + }) + } + router.WriteJSON(w, http.StatusOK, map[string]any{"items": out}) +} + +// isExternalURL classifies a menu item URL as external (points off +// this origin) or internal. The marketing renderer uses this to add +// `target="_blank" rel="noopener"` on external links. +// +// "External" here means "absolute with a scheme or scheme-relative". +// `mailto:` and `tel:` are treated as external because the renderer +// shouldn't add an internal Link wrapper to those. +func isExternalURL(u string) bool { + u = strings.TrimSpace(u) + if u == "" { + return false + } + if strings.HasPrefix(u, "//") { + return true + } + if i := strings.Index(u, ":"); i > 0 { + scheme := u[:i] + // Only treat as a scheme if it looks like one — lowercase + // ASCII letters/digits, no path-style characters. + for _, c := range scheme { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' { + continue + } + return false + } + return true + } + return false +} diff --git a/apps/api/internal/public/menus/handler_test.go b/apps/api/internal/public/menus/handler_test.go new file mode 100644 index 0000000..6c6bb84 --- /dev/null +++ b/apps/api/internal/public/menus/handler_test.go @@ -0,0 +1,236 @@ +package menus + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + pkgmenus "github.com/Singleton-Solution/GoNext/packages/go/menus" +) + +const base = "/api/v1/menus" + +type harness struct { + mux *http.ServeMux + store *pkgmenus.MemoryStore +} + +func newHarness(t *testing.T) *harness { + t.Helper() + store := pkgmenus.NewMemoryStore() + mux := http.NewServeMux() + if err := Mount(mux, base, Deps{ + Store: store, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }); err != nil { + t.Fatalf("Mount: %v", err) + } + return &harness{mux: mux, store: store} +} + +// seedMenu creates a menu with the given slug and the supplied label +// list. Each label gets a synthesised path (001, 002, ...) so the +// store accepts them. +func (h *harness) seedMenu(t *testing.T, slug, name string, items []seedItem) { + t.Helper() + m, err := h.store.CreateMenu(context.Background(), pkgmenus.Menu{Slug: slug, Name: name}) + if err != nil { + t.Fatalf("CreateMenu: %v", err) + } + for i, si := range items { + _, err := h.store.CreateItem(context.Background(), pkgmenus.MenuItem{ + MenuID: m.ID, + Path: pathToken(i + 1), + Label: si.label, + URL: si.url, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + } +} + +type seedItem struct{ label, url string } + +func pathToken(n int) string { + // Quick 1..999 → 3-digit zero-padded helper that satisfies the + // pathRe (^[0-9]{3}(\.[0-9]{3})*$) the store enforces. + if n < 10 { + return "00" + string(rune('0'+n)) + } + if n < 100 { + return "0" + intToASCII(n) + } + return intToASCII(n) +} + +func intToASCII(n int) string { + // Tiny inline itoa — avoids pulling strconv just for tests. + if n == 0 { + return "0" + } + digits := "" + for n > 0 { + digits = string(rune('0'+(n%10))) + digits + n /= 10 + } + return digits +} + +func TestByLocationReturnsItems(t *testing.T) { + h := newHarness(t) + h.seedMenu(t, "primary", "Primary", []seedItem{ + {"Pricing", "/pricing"}, + {"Docs", "/docs"}, + {"Status", "https://status.example.com"}, + }) + + req := httptest.NewRequest(http.MethodGet, base+"/by-location/primary", nil) + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var got struct { + Items []publicItem `json:"items"` + } + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Items) != 3 { + t.Fatalf("want 3 items, got %d (%+v)", len(got.Items), got.Items) + } + if got.Items[0].Label != "Pricing" || got.Items[0].Href != "/pricing" || got.Items[0].External { + t.Fatalf("item[0] wrong: %+v", got.Items[0]) + } + if !got.Items[2].External { + t.Fatalf("expected status link to be external: %+v", got.Items[2]) + } +} + +func TestByLocationMissingMenuReturnsEmpty(t *testing.T) { + h := newHarness(t) + + req := httptest.NewRequest(http.MethodGet, base+"/by-location/footer-product", nil) + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + + // Critical contract: missing menu is 200 with empty items, not 404. + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var got struct { + Items []publicItem `json:"items"` + } + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Items == nil { + t.Fatalf("items must be a JSON array, not null") + } + if len(got.Items) != 0 { + t.Fatalf("want 0 items, got %d", len(got.Items)) + } +} + +func TestByLocationEmptyMenuReturnsEmpty(t *testing.T) { + h := newHarness(t) + h.seedMenu(t, "primary", "Primary", nil) + + req := httptest.NewRequest(http.MethodGet, base+"/by-location/primary", nil) + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + // Validate the exact JSON envelope — empty items must serialise as + // `[]`, not `null`, so the typescript client doesn't need a null + // guard. + if body != "{\"items\":[]}\n" { + t.Fatalf("unexpected body: %q", body) + } +} + +func TestListReturnsConfiguredMenus(t *testing.T) { + h := newHarness(t) + h.seedMenu(t, "primary", "Primary", []seedItem{{"Pricing", "/pricing"}}) + h.seedMenu(t, "footer-product", "Footer Product", nil) + + req := httptest.NewRequest(http.MethodGet, base, nil) + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var got struct { + Menus []menuSummary `json:"menus"` + } + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Menus) != 2 { + t.Fatalf("want 2 menus, got %d (%+v)", len(got.Menus), got.Menus) + } +} + +func TestListEmptyStoreReturnsEmptyArray(t *testing.T) { + h := newHarness(t) + + req := httptest.NewRequest(http.MethodGet, base, nil) + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + if rec.Body.String() != "{\"menus\":[]}\n" { + t.Fatalf("unexpected body: %q", rec.Body.String()) + } +} + +func TestNoAuthRequired(t *testing.T) { + // The whole point of this surface is anonymous visitors hit it. + // The harness deliberately never injects a policy.Principal — if + // these routes ever pick up a gate, this test fails. + h := newHarness(t) + h.seedMenu(t, "primary", "Primary", []seedItem{{"Home", "/"}}) + + req := httptest.NewRequest(http.MethodGet, base+"/by-location/primary", nil) + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("anonymous read failed: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestIsExternalURL(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", false}, + {"/", false}, + {"/pricing", false}, + {"/docs/setup", false}, + {"#anchor", false}, + {"https://example.com", true}, + {"http://example.com", true}, + {"//example.com", true}, + {"mailto:hi@example.com", true}, + {"tel:+15551234567", true}, + } + for _, tc := range cases { + if got := isExternalURL(tc.in); got != tc.want { + t.Errorf("isExternalURL(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/apps/web/src/components/marketing/Footer.test.tsx b/apps/web/src/components/marketing/Footer.test.tsx index 56ce962..e355080 100644 --- a/apps/web/src/components/marketing/Footer.test.tsx +++ b/apps/web/src/components/marketing/Footer.test.tsx @@ -1,29 +1,59 @@ /** * Marketing footer — link column + brand contract. + * + * Footer is an async Server Component (it pulls the site name + + * tagline from the settings registry). Pre-resolve the async result + * to its rendered ReactElement so RTL can mount it synchronously. */ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import { MarketingFooter } from './Footer'; +async function renderFooter(props: Parameters[0] = {}) { + const element = await MarketingFooter({ + // The brand mark splits on the first space; pass the two-word + // form so the wordmark's bold + italic halves render the + // documented "Go" + "Next" pair. + siteName: 'Go Next', + siteTagline: + 'An all-in-one platform for content, hosting, and commerce.', + ...props, + }); + return render(element); +} + describe('', () => { - it('renders four column headings', () => { - const { container } = render(); + it('renders four column headings', async () => { + const { container } = await renderFooter(); const headings = container.querySelectorAll('h5'); expect(headings.length).toBe(4); const text = Array.from(headings).map((h) => h.textContent); expect(text).toEqual(['Product', 'Resources', 'Company', 'Legal']); }); - it('paints on a forest surface', () => { - const { container } = render(); + it('paints on a forest surface', async () => { + const { container } = await renderFooter(); const footer = container.querySelector('footer'); expect(footer?.getAttribute('data-surface')).toBe('forest'); }); - it('renders the brand wordmark in the brand-foot column', () => { - const { container } = render(); + it('renders the brand wordmark in the brand-foot column', async () => { + const { container } = await renderFooter(); expect(container.querySelector('.wm-go')?.textContent).toBe('Go'); expect(container.querySelector('.wm-next')?.textContent).toBe('Next'); }); + + it('uses the configured tagline in the brand column', async () => { + const { container } = await renderFooter({ + siteTagline: 'Calm software, alive.', + }); + expect(container.textContent).toContain('Calm software, alive.'); + }); + + it('stamps the configured site name into the © line', async () => { + const { container } = await renderFooter({ siteName: 'Acme Studio' }); + const year = new Date().getFullYear(); + expect(container.textContent).toContain(`© ${year} Acme Studio`); + }); }); diff --git a/apps/web/src/components/marketing/Footer.tsx b/apps/web/src/components/marketing/Footer.tsx index 05d1744..05e9cd6 100644 --- a/apps/web/src/components/marketing/Footer.tsx +++ b/apps/web/src/components/marketing/Footer.tsx @@ -5,45 +5,28 @@ * The brand-foot wordmark in the first column uses the wordmark * primitive with `surface="forest"` so "Next" lifts to emerald-bright, * matching the kit's `.brand-foot .wm-next` rule. + * + * Server Component — wordmark, tagline, and the © line read from the + * `core.site.*` registry via `fetchSiteOptions`. Callers can pass the + * fields in directly when the page already fetched them, but the + * default-undefined path is the common one (no caller has to know + * about settings just to render the footer). + * + * Link columns: each column reads from a named menu location + * (`footer-product`, `footer-resources`, `footer-company`, + * `footer-legal`). An empty/missing menu renders an empty column. + * The shipping defaults are gone — once an admin can curate the + * footer, the source of truth has to be the menus store. See #509. */ import Link from 'next/link'; import type { ReactElement } from 'react'; import { Wordmark } from '@/components/brand/Wordmark'; - -const PRODUCT = [ - { href: '/editor', label: 'Editor' }, - { href: '/hosting', label: 'Hosting' }, - { href: '/commerce', label: 'Commerce' }, - { href: '/analytics', label: 'Analytics' }, - { href: '/marketplace', label: 'Marketplace' }, -]; - -const RESOURCES = [ - { href: '/docs', label: 'Docs' }, - { href: '/changelog', label: 'Changelog' }, - { href: '/status', label: 'Status' }, - { href: '/importer', label: 'Importer' }, - { href: '/api', label: 'API' }, -]; - -const COMPANY = [ - { href: '/about', label: 'About' }, - { href: '/customers', label: 'Customers' }, - { href: '/careers', label: 'Careers' }, - { href: '/press', label: 'Press' }, -]; - -const LEGAL = [ - { href: '/privacy', label: 'Privacy' }, - { href: '/terms', label: 'Terms' }, - { href: '/security', label: 'Security' }, - { href: '/dpa', label: 'DPA' }, -]; +import { fetchMenu, fetchSiteOptions, type MenuItem } from '@/lib/api'; interface ColumnProps { heading: string; - links: ReadonlyArray<{ href: string; label: string }>; + links: ReadonlyArray; } function Column({ heading, links }: ColumnProps): ReactElement { @@ -54,13 +37,24 @@ function Column({ heading, links }: ColumnProps): ReactElement {

@@ -68,7 +62,31 @@ function Column({ heading, links }: ColumnProps): ReactElement { ); } -export function MarketingFooter(): ReactElement { +export interface MarketingFooterProps { + /** Site name from `core.site.name` — used for wordmark + © line. */ + siteName?: string; + /** Tagline from `core.site.tagline` — used as the brand-column copy. */ + siteTagline?: string; +} + +export async function MarketingFooter({ + siteName, + siteTagline, +}: MarketingFooterProps = {}): Promise { + // Settings + four menu columns in parallel — no read depends on + // another. The menu reads share the same 5-minute revalidate window + // as the primary nav. + const needsOpts = siteName === undefined || siteTagline === undefined; + const [opts, product, resources, company, legal] = await Promise.all([ + needsOpts ? fetchSiteOptions({ revalidate: 60 }) : Promise.resolve(null), + fetchMenu('footer-product', { revalidate: 300 }), + fetchMenu('footer-resources', { revalidate: 300 }), + fetchMenu('footer-company', { revalidate: 300 }), + fetchMenu('footer-legal', { revalidate: 300 }), + ]); + const resolvedName = siteName ?? opts?.name ?? ''; + const resolvedTagline = siteTagline ?? opts?.tagline ?? ''; + return (