Skip to content

feat: Wave 2 — admin → public site wiring (4 medium features)#528

Open
tayebmokni wants to merge 5 commits into
feat/wave-1-quick-winsfrom
feat/wave-2-public-site-wiring
Open

feat: Wave 2 — admin → public site wiring (4 medium features)#528
tayebmokni wants to merge 5 commits into
feat/wave-1-quick-winsfrom
feat/wave-2-public-site-wiring

Conversation

@tayebmokni
Copy link
Copy Markdown
Contributor

Stacked on PR #527 (Wave 1) which stacked on PR #523 (post-session cleanup).

Closes 4 issues from the #522 tracker

5 commits

  1. feat(admin): build /posts/new, /pages/new, /posts/import, /jobs pages (#507)
  2. feat(api+web): wire admin menus → public site navigation (#509) — includes new apps/api/internal/public/menus/ package
  3. feat(web): wire admin Settings → public site identity (#508) — web side
  4. feat(api): public-read /api/v1/public/site for site identity (#508) — API side, anonymous-readable so the web container can fetch without a session
  5. feat(web): show_on_front dispatcher — admin pin → public homepage (#510) — extends public/site with reading.{homepage_type,homepage_page_id}

Verification

  • go build ./... clean
  • pnpm exec tsc --noEmit 0 errors in apps/admin and apps/web
  • New tests: 6 (public/menus) + 6 (public/settings) + 4 (web home dispatcher) + 22 (admin route smoke tests) = 38 new tests
  • Live verified end-to-end:
    • GET /api/v1/public/site returns 200 with seeded core.site.* + reading projection (no auth)
    • GET /api/v1/menus/by-location/primary returns 200 with empty array on fresh install
    • curl localhost:3000/ HTML shows <title> and <meta og:site_name> from postgres
    • New admin routes /posts/new etc. render and route correctly

What's next

🤖 Generated with Claude Code

tib0o0o and others added 5 commits May 28, 2026 12:12
…#507)

Four \`<Link>\` 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 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
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 \`<a target="_blank" rel="noopener
noreferrer">\`; 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 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
Public site title, OG metadata, wordmark, footer copyright now read
from /api/v1/settings?group=core.site instead of hardcoded "GoNext".

### apps/web/src/lib/api.ts (extended in the previous commit)

\`fetchSiteOptions({revalidate, cookie})\` hits the API and returns
\`{name, tagline, url}\`. Returns documented defaults ("GoNext",
"A site powered by GoNext.", "") on any fetch error or non-200 —
the public site never crashes on a settings hiccup.

### apps/web/src/app/layout.tsx

Replaces static \`metadata\` export with \`async generateMetadata()\`
that calls fetchSiteOptions and populates:
- \`title.default\` (siteName)
- \`title.template\` (\`%s — \${siteName}\`)
- \`description\` (tagline)
- \`metadataBase\` (only when url is non-empty AND parses)
- \`openGraph.siteName\`

revalidate: 60s — settings change rarely, so a Next data-cache
window of one minute is plenty.

### Marketing chrome

Nav.tsx + Footer.tsx (committed in #509 together with menus) are
now async Server Components that pull site name + tagline. The
Wordmark primitive accepts a \`name\` prop, splits on the FIRST
space, renders first half display-bold + second half italic serif.
Single-word names render bold-only.

### PublicShell

Wrapped the async chrome in \`<Suspense fallback={null}>\` so route-
page tests in jsdom still render the themed body while the async
data hasn't resolved.

### Known follow-up

/api/v1/settings is currently RequireSession-gated, so the public
web container always gets 401 and falls back to defaults. A public
read carve-out lands in the next commit so admin-edited values
actually surface on the public site.

Closes #508 (web-side wiring). API public read in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
PR #508's web-side wiring was rendering default values on every page
load because /api/v1/settings is RequireSession-gated — the public
web container has no session cookie. The graceful-fallback path was
firing on every request, so admin edits never surfaced.

New: \`apps/api/internal/public/settings/\` mounting:
- \`GET /api/v1/public/site\` — anonymous-readable. Returns the
  flat projection \`{name, tagline, url}\` for the publicly-safe
  core.site.* keys. Store error returns documented defaults
  (\"GoNext\" / \"A site powered by GoNext.\" / \"\") with HTTP 200
  — never 5xx.

Wired into main.go next to the admin settings mount, reusing the
same settingsStore so operator edits surface immediately on the
public site.

apps/web/src/lib/api.ts::fetchSiteOptions now hits the public
endpoint and dropped the cookie-forwarding code (no longer needed).

6 tests cover empty store, partial keys, store error graceful path,
no-auth contract, flat wire shape, nil-Deps Mount validation.

Verified live:
  curl http://localhost:8080/api/v1/public/site
  → 200 {\"name\":\"Verified Live Save\",\"tagline\":\"...\",\"url\":\"...\"}

  curl http://localhost:3000/
  → <title>Verified Live Save</title> in the rendered HTML
  → <meta property=\"og:site_name\" content=\"Verified Live Save\">

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
The TODO at apps/web/src/app/page.tsx:17-19 promised that admin
\`core.reading.homepage_type=static_page\` + \`homepage_page_id\` would
render the chosen page at /. The dispatcher is now wired.

### Public-read extension

apps/api/internal/public/settings/handler.go projects two more
fields next to the existing \`{name, tagline, url}\`:

    "reading": {
      "homepage_type": "latest_posts" | "static_page",
      "homepage_page_id": "<slug>" | ""
    }

Enum-guard: an invalid stored homepage_type clamps to
\`latest_posts\` rather than propagating the bad value.

Two new tests: TestReadingProjectionSurfacesStoredValues,
TestInvalidHomepageTypeFallsBackToDefault.

### Web

apps/web/src/lib/api.ts: SiteOptions grows a \`reading\` field
(camel-case keys). Defaults: type=latest_posts, id="".

apps/web/src/app/page.tsx: when homepage_type === 'static_page'
AND homepage_page_id is non-empty, dispatcher calls
renderSingular(id) and wraps with PublicShell. Otherwise, falls
through to the existing marketing landing.

Graceful degrade: empty id, renderSingular non-200, or any thrown
error → marketing landing. The dispatcher never crashes the home
route on misconfig.

page.test.tsx covers all 4 branches: default, static_page+valid,
static_page+empty-id (fall back), static_page+404 (fall back).

Closes #510.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Tayeb Mokni <tayeb.mokni@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants