Skip to content

Commit fb4affd

Browse files
Merge pull request #19 from nyxCore-Systems/feat/homepage-faq-region-seo
feat(seo): Homepage-FAQ (eine FAQPage) + Region-Begriffe + IndexNow
2 parents a6492e1 + b917992 commit fb4affd

16 files changed

Lines changed: 1019 additions & 64 deletions

File tree

docs/superpowers/plans/2026-07-04-homepage-faq-region-indexnow.md

Lines changed: 518 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# Homepage FAQ + Region SEO — Design
2+
3+
**Status:** approved (brainstorm) — 2026-07-04
4+
5+
## Goal
6+
7+
Guarantee a festival FAQ on the homepage — visible **and** emitting exactly one
8+
`FAQPage` JSON-LD — in **both** homepage layouts (the admin-composed DB sections
9+
*and* the code-default Noir layout), fix the existing duplicate `FAQPage`, and
10+
broaden the site's regional terms ("Norddeutschland" / "Niedersachsen") for
11+
generic "Festivals im Norden"-style discovery.
12+
13+
Additionally, wire up **IndexNow** (host the ownership key file and auto-submit
14+
changed URLs to IndexNow — which shares them with Bing and other participating
15+
engines — on admin publish/update).
16+
17+
This is an on-page SEO/AEO improvement. No new dependencies, no schema change.
18+
19+
## Background / current state (verified)
20+
21+
- **Homepage** (`src/app/(public)/page.tsx`): renders `HomepageSectionRenderer`
22+
when `getHomepageSections()` returns DB sections, otherwise the code default
23+
(`NOIR_DEFAULT_LAYOUT` of `NoirElement`s). It already emits `websiteJsonLd` +
24+
`getMusicFestivalJsonLd()`.
25+
- **Title/meta are already strong**: `generateMetadata` sets the date
26+
("7. & 8. August 2026"), region ("Ventschau (Landkreis Lüneburg)") and genre
27+
("Blues-Rock, Funk und Latin"). Only the explicit *broader* region terms
28+
("Norddeutschland" / "Niedersachsen") are missing.
29+
- **FAQ today only appears for DB-composed homepages.** A DB `faq`
30+
`HomepageSection` renders visibly via `src/components/sections/FAQ.tsx`
31+
(case `'faq'` in `HomepageSectionRenderer`) and its `FAQPage` JSON-LD comes
32+
from `SectionStructuredData` (rendered only inside the DB-sections branch).
33+
The **default Noir layout shows no FAQ at all.**
34+
- **Two latent bugs to fix as part of this work:**
35+
1. **Duplicate `FAQPage`**: `FAQ.tsx` emits its *own* `FAQPage` JSON-LD
36+
(lines ~67–91) *and* `SectionStructuredData` emits one for the same
37+
section → two identical `FAQPage` blocks on a DB-composed homepage.
38+
2. **Wrong default content**: `FAQ.tsx`'s `defaultData` is knife-shop copy
39+
left over from the "minicms" skeleton — irrelevant to this festival.
40+
41+
## Approach (chosen: "A — always-on, single JSON-LD source")
42+
43+
A small pure module resolves *the* homepage FAQ (DB section if present, else
44+
data-driven festival defaults). The homepage renders it in both layouts and
45+
emits exactly one `FAQPage`. `FAQ.tsx` becomes presentational (its self-emitted
46+
JSON-LD is removed, killing the duplicate). Region terms ride in the "Wo?"
47+
answer (visible + in JSON-LD) plus the homepage meta.
48+
49+
### Single-`FAQPage` invariant
50+
51+
`hasDbFaq = sections.some(s => s.type === 'faq' && s.isVisible)`
52+
53+
| Case | Visible FAQ | `FAQPage` JSON-LD |
54+
| --- | --- | --- |
55+
| Default layout (no sections) | always-on FAQ (page.tsx) | one, from page.tsx |
56+
| DB-composed, has `faq` section | existing renderer FAQ | one, from `SectionStructuredData` |
57+
| DB-composed, no `faq` section | always-on FAQ (page.tsx) | one, from page.tsx |
58+
59+
The always-on FAQ and its JSON-LD render **only when `!hasDbFaq`**, so the two
60+
sources never both fire. `FAQ.tsx` no longer emits JSON-LD in any case.
61+
62+
## Files
63+
64+
### 1. `src/lib/faq.ts` (NEW — pure, unit-tested)
65+
66+
No React / Prisma imports (keeps it testable, mirrors `media-query.ts`).
67+
68+
```ts
69+
export interface FaqItem {
70+
question: string
71+
answer: string
72+
}
73+
74+
/** German long-date label for the festival, e.g. "7. und 8. August 2026".
75+
* Same day for start/end collapses to a single date. */
76+
export function formatFestivalDateLabel(start: Date, end: Date | null): string
77+
78+
/** The default festival FAQ, built from event date + location. The "Wo?" answer
79+
* carries the broad region terms (Norddeutschland / Niedersachsen). */
80+
export function festivalFaqDefaults(input: {
81+
dateLabel: string
82+
location: string
83+
}): FaqItem[]
84+
85+
/** Pick the active homepage FAQ: a non-empty DB faq section wins; otherwise the
86+
* data-driven defaults. */
87+
export function resolveHomepageFaq(
88+
dbItems: FaqItem[] | null,
89+
defaults: FaqItem[],
90+
): { items: FaqItem[]; fromDb: boolean }
91+
```
92+
93+
`festivalFaqDefaults` returns these five items (content approved in brainstorm;
94+
`{dateLabel}` / `{location}` interpolated):
95+
96+
1. **Wann findet das e-Ventschau-Festival 2026 statt?** — „Am **{dateLabel}**
97+
(Freitag & Samstag), Open Air auf dem Hof."
98+
2. **Wo findet das Festival statt?** — „Auf **{location}**, Landkreis Lüneburg –
99+
im Norden **Niedersachsens** (**Norddeutschland**)."
100+
3. **Was kostet der Eintritt?** — „Zahl-was-du-kannst: sozial verträglicher
101+
Eintritt, 100 % Benefiz."
102+
4. **Welche Musik läuft?** — „Internationale Live-Musik – **Blues-Rock, Funk,
103+
Latin** u. a., dazu Ausstellungen, Vorträge und Kinderprogramm."
104+
5. **Anreise & Camping?** — „Anfahrt nach Ventschau; Camping frei auf der Wiese."
105+
106+
### 2. `src/components/sections/FAQ.tsx` (MODIFY)
107+
108+
- Remove the self-emitted `<script type="application/ld+json">` block and the
109+
`faqSchema` const (fixes the duplicate `FAQPage`). Component is now purely
110+
presentational.
111+
- Replace the knife `defaultData` with a harmless neutral fallback
112+
(`{ title: 'Häufig gestellte Fragen', subtitle: '', items: [] }`) so stray
113+
skeleton content can never render. `FAQItem`/`FAQData` types and props are
114+
unchanged; `HomepageSectionRenderer` always passes `data`.
115+
116+
### 3. `src/app/(public)/page.tsx` (MODIFY)
117+
118+
- Compute `hasDbFaq` from `sections`; extract `dbItems` from the visible `faq`
119+
section's `content.items` (or `null`).
120+
- Build `defaults = festivalFaqDefaults({ dateLabel, location })` where
121+
`dateLabel`/`location` come from `getFeaturedEvent()`
122+
(`formatFestivalDateLabel(startDate, endDate)` / `locationName`) with static
123+
fallbacks ("7. und 8. August 2026" / "Hof Thiele, Ventschau") when no featured
124+
event exists.
125+
- After the sections/default block, when `!hasDbFaq`, render both:
126+
- `<JsonLd data={buildFaqJsonLd(faq.items)} />` (reuse existing
127+
`buildFaqJsonLd` from `lib/seo.ts` — do **not** hand-roll JSON-LD), and
128+
- `<FAQ data={{ title: 'Häufig gestellte Fragen', subtitle: 'Alles Wichtige zu Termin, Ort, Anreise und Eintritt.', items: faq.items }} />`.
129+
130+
### 4. `src/app/(public)/page.tsx``generateMetadata` (MODIFY)
131+
132+
- Add `'Norddeutschland'` and `'Niedersachsen'` to `keywords`.
133+
- Weave the region into the existing description by changing the location
134+
clause from "in Ventschau (Landkreis Lüneburg)" to "in Ventschau (Landkreis
135+
Lüneburg, **Norddeutschland**)". One-word insertion; keeps the sentence
136+
natural and the meaningful lead well under ~160 chars.
137+
138+
### 5. `src/lib/__tests__/faq.test.ts` (NEW)
139+
140+
- `formatFestivalDateLabel`: two-day range → "7. und 8. August 2026"; single day
141+
→ one date; handles `end === null`.
142+
- `festivalFaqDefaults`: interpolates `dateLabel`/`location`; the "Wo?" answer
143+
contains both "Niedersachsen" and "Norddeutschland"; returns 5 items.
144+
- `resolveHomepageFaq`: non-empty `dbItems` wins (`fromDb: true`); `null`/empty
145+
→ defaults (`fromDb: false`).
146+
147+
## Testing
148+
149+
- Pure helpers in `lib/faq.ts` covered by `node:test` (`npm test`).
150+
- Wiring verified by `npx tsc --noEmit` + `npm run build`.
151+
- Manual/inspection: exactly one `FAQPage` block in the rendered homepage HTML
152+
in each of the three cases above.
153+
154+
## IndexNow (chosen: "auto-ping on publish")
155+
156+
Protocol (confirmed from indexnow.org): host `https://e-ventschau.de/{key}.txt`
157+
containing exactly the key; submit changes via `POST https://api.indexnow.org/indexnow`
158+
with `Content-Type: application/json; charset=utf-8` and body
159+
`{ host, key, keyLocation, urlList }` (≤10,000 URLs). A root-level key covers all
160+
paths; submitting to one engine shares with all. Key
161+
`3488822b5c7046ca8b5bcb16286d3d0b` (32 hex chars) is valid.
162+
163+
### 6. `public/3488822b5c7046ca8b5bcb16286d3d0b.txt` (NEW)
164+
165+
Static file, content = the key verbatim (`3488822b5c7046ca8b5bcb16286d3d0b`, no
166+
trailing newline). Served by Next.js at
167+
`https://e-ventschau.de/3488822b5c7046ca8b5bcb16286d3d0b.txt`.
168+
169+
### 7. `src/lib/indexnow.ts` (NEW — pure builders unit-tested; `submitUrls` is the thin side effect)
170+
171+
```ts
172+
export const INDEXNOW_KEY: string // process.env.INDEXNOW_KEY || the key above
173+
174+
/** True only for the real prod host over https (never localhost/dev). */
175+
export function indexNowEnabled(siteUrl?: string): boolean
176+
177+
/** Dedupe + map paths to absolute prod URLs, dropping off-host/invalid ones. */
178+
export function toAbsoluteUrls(paths: string[], siteUrl?: string): string[]
179+
180+
/** The POST body: { host, key, keyLocation, urlList }. */
181+
export function buildIndexNowBody(urls: string[], siteUrl?: string, key?: string): {
182+
host: string; key: string; keyLocation: string; urlList: string[]
183+
}
184+
185+
/** Best-effort, never throws. No-ops unless indexNowEnabled() and NODE_ENV==='production'. */
186+
export async function submitUrls(paths: string[]): Promise<void>
187+
```
188+
189+
`SITE_URL` = `process.env.NEXT_PUBLIC_SITE_URL || 'https://e-ventschau.de'`
190+
(same as `sitemap.ts`/`seo.ts`). `keyLocation = ${SITE_URL}/${INDEXNOW_KEY}.txt`.
191+
192+
### 8. Admin route hooks (MODIFYfire-and-forget `void submitUrls(paths)` after a successful mutation)
193+
194+
The routes run on the Node runtime and already do post-mutation side effects
195+
(`revalidatePath`), so a non-blocking ping fits. URL mapping mirrors `sitemap.ts`:
196+
197+
| Route | When | Paths submitted |
198+
| --- | --- | --- |
199+
| `pages/route.ts` POST, `pages/[id]/route.ts` PUT | page is `isPublished` | `[page.path || '/'+page.slug]` |
200+
| `artists/route.ts` POST, `artists/[id]/route.ts` PUT | artist `isPublished && isActive` | `['/kuenstler/'+slug, '/kuenstler']` |
201+
| `events/route.ts` POST, `events/[id]/route.ts` PUT | event `isPublished && isActive` | `['/events/'+slug, '/events']` |
202+
| `sections/route.ts` POST/PUT/DELETE, `sections/reorder`, `sections/import-homepage` | any | `['/']` |
203+
204+
A tiny local call (`void submitUrls([...])`) at each site; no `await` (best-effort,
205+
must not add latency or fail the save). `submitUrls` swallows all errors.
206+
207+
### 9. `src/lib/__tests__/indexnow.test.ts` (NEW)
208+
209+
- `indexNowEnabled`: `https://e-ventschau.de`true; `http://…` and
210+
`http://localhost:3000`false.
211+
- `toAbsoluteUrls`: prefixes SITE_URL, dedupes, drops off-host/empty.
212+
- `buildIndexNowBody`: correct `host`, `keyLocation` (`…/{key}.txt`), `urlList`,
213+
and `key` echoes `INDEXNOW_KEY`.
214+
- Key format guard: `INDEXNOW_KEY` matches `/^[A-Za-z0-9-]{8,128}$/`, and
215+
`public/${INDEXNOW_KEY}.txt` exists and its trimmed content equals the key
216+
(catches filename/constant drift).
217+
218+
## Out of scope (declined in brainstorm)
219+
220+
- Re-adding a hero date/location tile (`dateMeta`).
221+
- A featured-event-missing hero-kicker fallback.
222+
- A full editable `noir_faq` element type (Approach B).
223+
- Off-page work (Bing Webmaster / GSC / backlinks) — manual, tracked in memory.
224+
225+
## Global Constraints
226+
227+
- German UI copy. No new dependencies. No Prisma schema change.
228+
- Reuse `buildFaqJsonLd` (`lib/seo.ts`) for JSON-LD; never emit more than one
229+
`FAQPage` on the homepage.
230+
- `lib/faq.ts` and `lib/indexnow.ts` keep their pure builders import-free of
231+
React/Prisma (unit-testable); only `submitUrls` does I/O.
232+
- IndexNow submission is **best-effort and never blocks or fails an admin save**;
233+
it no-ops outside production / the prod host. The key is public (hosted), so it
234+
is not a secreta hardcoded default is acceptable, `INDEXNOW_KEY` env overrides.
235+
- Deploy is push-to-`main`; ship as a PR (no direct main writes).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3488822b5c7046ca8b5bcb16286d3d0b

src/app/(public)/page.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import type { Metadata } from 'next'
22
import { getSiteSettings } from '@/lib/menu'
3-
import { buildMetadata, websiteJsonLd, getMusicFestivalJsonLd } from '@/lib/seo'
3+
import { buildMetadata, websiteJsonLd, getMusicFestivalJsonLd, buildFaqJsonLd } from '@/lib/seo'
44
import JsonLd from '@/components/JsonLd'
55
import HomepageSectionRenderer from '@/components/sections/HomepageSectionRenderer'
66
import NoirElement from '@/components/noir/sections/NoirElement'
77
import { getHomepageSections } from '@/lib/sections'
88
import { NOIR_DEFAULT_LAYOUT } from '@/lib/noir-home-defaults'
9+
import FAQ from '@/components/sections/FAQ'
10+
import { getFeaturedEvent } from '@/lib/events'
11+
import { festivalFaqDefaults, formatFestivalDateLabel } from '@/lib/faq'
912

1013
export const dynamic = 'force-dynamic'
1114

@@ -14,12 +17,14 @@ export async function generateMetadata(): Promise<Metadata> {
1417
const metadata = buildMetadata(null, '/', {
1518
title: 'Das 11. Benefiz-Festival am 7. & 8. August 2026',
1619
description:
17-
'Internationale Live-Musik für den guten Zweck: Das e-Ventschau-Benefiz-Festival in Ventschau (Landkreis Lüneburg) vereint Blues-Rock, Funk und Latin mit Ausstellungen und Vorträgen – zugunsten von Opfern nuklearer Katastrophen in Tschernobyl und Fukushima.',
20+
'Internationale Live-Musik für den guten Zweck: Das e-Ventschau-Benefiz-Festival in Ventschau (Landkreis Lüneburg, Norddeutschland) vereint Blues-Rock, Funk und Latin mit Ausstellungen und Vorträgen – zugunsten von Opfern nuklearer Katastrophen in Tschernobyl und Fukushima.',
1821
keywords: [
1922
'e-Ventschau',
2023
'Benefiz-Festival',
2124
'Musikfestival Lüneburg',
2225
'Ventschau',
26+
'Norddeutschland',
27+
'Niedersachsen',
2328
'Open Air',
2429
'Blues Rock',
2530
'Funk',
@@ -39,15 +44,28 @@ export async function generateMetadata(): Promise<Metadata> {
3944
}
4045

4146
export default async function HomePage() {
42-
const [sections, festivalJsonLd] = await Promise.all([
47+
const [sections, festivalJsonLd, featured] = await Promise.all([
4348
getHomepageSections(),
4449
getMusicFestivalJsonLd(),
50+
getFeaturedEvent(),
4551
])
4652

53+
// The default festival FAQ appears whenever the homepage does NOT already
54+
// carry an admin-authored `faq` section (which HomepageSectionRenderer +
55+
// SectionStructuredData render + emit on their own). This guarantees exactly
56+
// one visible FAQ and one FAQPage in every layout.
57+
const hasDbFaq = sections.some((s) => s.type === 'faq' && s.isVisible)
58+
const dateLabel = featured
59+
? formatFestivalDateLabel(featured.startDate, featured.endDate)
60+
: '7. und 8. August 2026'
61+
const location = featured?.locationName || 'Hof Thiele, Ventschau'
62+
const faqItems = festivalFaqDefaults({ dateLabel, location })
63+
4764
return (
4865
<div className="nh">
4966
<JsonLd data={websiteJsonLd} />
5067
{festivalJsonLd && <JsonLd data={festivalJsonLd} />}
68+
{!hasDbFaq && <JsonLd data={buildFaqJsonLd(faqItems)} />}
5169
{sections.length > 0 ? (
5270
// Editor-composed homepage: ordered/visible elements from /admin/sections
5371
<HomepageSectionRenderer sections={sections} />
@@ -56,6 +74,15 @@ export default async function HomePage() {
5674
// also the starting point the "import current homepage" action recreates.
5775
NOIR_DEFAULT_LAYOUT.map((type) => <NoirElement key={type} type={type} />)
5876
)}
77+
{!hasDbFaq && (
78+
<FAQ
79+
data={{
80+
title: 'Häufig gestellte Fragen',
81+
subtitle: 'Alles Wichtige zu Termin, Ort, Anreise und Eintritt.',
82+
items: faqItems,
83+
}}
84+
/>
85+
)}
5986
</div>
6087
)
6188
}

src/app/api/admin/artists/[id]/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getToken } from 'next-auth/jwt'
44
import { prisma } from '@/lib/prisma'
55
import { getTenant } from '@/lib/tenant'
66
import { normalizeSlug, isValidSlug, safeHttpsUrl, safeCloudinaryUrl, sanitizeSocials, sanitizeGalleryItem } from '@/lib/artist-validation'
7+
import { submitUrls } from '@/lib/indexnow'
78

89
async function getSessionToken() {
910
const cookieStore = await cookies()
@@ -93,6 +94,11 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
9394
}
9495

9596
const updated = await prisma.artist.findUnique({ where: { id }, include: { media: { orderBy: { sortOrder: 'asc' } } } })
97+
98+
if (updated?.isPublished && updated.isActive) {
99+
void submitUrls([`/kuenstler/${updated.slug}`, '/kuenstler'])
100+
}
101+
96102
return NextResponse.json(updated)
97103
}
98104

src/app/api/admin/artists/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
55
import { getTenant } from '@/lib/tenant'
66
import { normalizeSlug, isValidSlug, safeHttpsUrl, safeCloudinaryUrl, sanitizeSocials } from '@/lib/artist-validation'
77
import { getArtistsForAdmin } from '@/lib/artists'
8+
import { submitUrls } from '@/lib/indexnow'
89

910
async function getSessionToken() {
1011
const cookieStore = await cookies()
@@ -65,5 +66,11 @@ export async function POST(req: NextRequest) {
6566
updatedById: (token.sub as string) || null,
6667
},
6768
})
69+
// A newly created artist can be published immediately (unlike pages/events,
70+
// which are created as drafts and pinged on the publish PUT). Best-effort;
71+
// no-ops off-prod and never throws.
72+
if (artist.isPublished && artist.isActive) {
73+
void submitUrls([`/kuenstler/${artist.slug}`, '/kuenstler'])
74+
}
6875
return NextResponse.json(artist, { status: 201 })
6976
}

src/app/api/admin/events/[id]/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { prisma } from '@/lib/prisma'
33
import { authTenant } from '@/lib/admin-auth'
4+
import { submitUrls } from '@/lib/indexnow'
45
import {
56
normalizeSlug, isValidSlug, safeHttpsUrl, safeCloudinaryUrl,
67
sanitizeEventType, sanitizePriceTier,
@@ -97,6 +98,11 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
9798
}
9899

99100
const updated = await prisma.event.findUnique({ where: { id }, include: EVENT_INCLUDE })
101+
102+
if (updated?.isPublished && updated.isActive) {
103+
void submitUrls([`/events/${updated.slug}`, '/events'])
104+
}
105+
100106
return NextResponse.json(updated)
101107
}
102108

src/app/api/admin/pages/[id]/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { cookies } from 'next/headers'
44
import { getToken } from 'next-auth/jwt'
55
import { prisma } from '@/lib/prisma'
66
import { simpleHash } from '@/lib/hash'
7+
import { submitUrls } from '@/lib/indexnow'
78

89
async function getSessionToken() {
910
const cookieStore = await cookies()
@@ -175,6 +176,10 @@ export async function PUT(
175176
}
176177
}
177178

179+
if (updated.isPublished) {
180+
void submitUrls([updated.path || `/${updated.slug}`])
181+
}
182+
178183
return NextResponse.json(updated)
179184
} catch (err) {
180185
console.error('PUT /api/admin/pages/[id] error:', err)

0 commit comments

Comments
 (0)