|
| 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 (MODIFY — fire-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 secret — a hardcoded default is acceptable, `INDEXNOW_KEY` env overrides. |
| 235 | +- Deploy is push-to-`main`; ship as a PR (no direct main writes). |
0 commit comments