Skip to content

Commit 7ab996c

Browse files
feat(seo): MusicFestival JSON-LD on homepage (Wave 2 entity)
Establishes e-Ventschau as a recurring festival brand for Knowledge Graph and AI search: a MusicFestival entity on / linking the venue (Place with structured PostalAddress + geo), organizer (e.V.), the chain of published editions (subEvent) and social profiles (sameAs). Data-driven & editable (no hardcoding, no schema drift): - SiteSettings gains nullable JSON columns socialLinks + contactAddress, updated in schema.prisma AND the migration together. - /admin/setup form + settings API persist them; getSiteSettings normalizes. - Organization JSON-LD (rendered on every page) converted to an async getOrganizationJsonLd() so the editable social list feeds its sameAs; Facebook kept as a fallback so the site-wide entity never loses it. Pure builders (buildMusicFestivalJsonLd + buildPlace, normalizeSocialLinks, normalizeContactAddress) are TDD-covered in src/lib/__tests__. tsc + build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6912aaa commit 7ab996c

11 files changed

Lines changed: 482 additions & 16 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Wave 2 SEO: editable social profiles + structured venue/contact address.
2+
-- Both nullable JSONB, no default — existing rows keep working unchanged.
3+
ALTER TABLE "SiteSettings" ADD COLUMN "socialLinks" JSONB;
4+
ALTER TABLE "SiteSettings" ADD COLUMN "contactAddress" JSONB;

prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ model SiteSettings {
9898
openaiApiKeyEncrypted String? @db.Text
9999
openaiModel String @default("auto")
100100
maintenanceMode Boolean @default(false)
101+
socialLinks Json?
102+
contactAddress Json?
101103
createdAt DateTime @default(now())
102104
updatedAt DateTime @updatedAt
103105

src/app/(public)/page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Metadata } from 'next'
22
import { getSiteSettings } from '@/lib/menu'
3-
import { buildMetadata, websiteJsonLd } from '@/lib/seo'
3+
import { buildMetadata, websiteJsonLd, getMusicFestivalJsonLd } from '@/lib/seo'
44
import JsonLd from '@/components/JsonLd'
55
import HomepageSectionRenderer from '@/components/sections/HomepageSectionRenderer'
66
import NoirElement from '@/components/noir/sections/NoirElement'
@@ -39,11 +39,15 @@ export async function generateMetadata(): Promise<Metadata> {
3939
}
4040

4141
export default async function HomePage() {
42-
const sections = await getHomepageSections()
42+
const [sections, festivalJsonLd] = await Promise.all([
43+
getHomepageSections(),
44+
getMusicFestivalJsonLd(),
45+
])
4346

4447
return (
4548
<div className="nh">
4649
<JsonLd data={websiteJsonLd} />
50+
{festivalJsonLd && <JsonLd data={festivalJsonLd} />}
4751
{sections.length > 0 ? (
4852
// Editor-composed homepage: ordered/visible elements from /admin/sections
4953
<HomepageSectionRenderer sections={sections} />

src/app/admin/setup/page.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ interface SiteSettings {
2626
hasOpenaiApiKey: boolean
2727
openaiModel: string
2828
maintenanceMode: boolean
29+
socialLinks: string[] | null
30+
contactAddress: {
31+
venueName?: string
32+
street?: string
33+
postalCode?: string
34+
locality?: string
35+
region?: string
36+
country?: string
37+
lat?: number
38+
lng?: number
39+
} | null
2940
}
3041

3142
interface MenuItem {
@@ -74,6 +85,10 @@ export default function SetupPage() {
7485
const [openaiModel, setOpenaiModel] = useState('auto')
7586
const [showApiKey, setShowApiKey] = useState(false)
7687
const [maintenanceMode, setMaintenanceMode] = useState(false)
88+
const [socialLinksText, setSocialLinksText] = useState('')
89+
const [address, setAddress] = useState({
90+
venueName: '', street: '', postalCode: '', locality: '', region: '', country: '', lat: '', lng: '',
91+
})
7792

7893
const fetchData = useCallback(async () => {
7994
try {
@@ -96,6 +111,13 @@ export default function SetupPage() {
96111
setHasOpenaiApiKey(settingsData.hasOpenaiApiKey ?? false)
97112
setOpenaiModel(settingsData.openaiModel ?? 'auto')
98113
setMaintenanceMode(settingsData.maintenanceMode ?? false)
114+
setSocialLinksText(Array.isArray(settingsData.socialLinks) ? settingsData.socialLinks.join('\n') : '')
115+
const ca = settingsData.contactAddress ?? {}
116+
setAddress({
117+
venueName: ca.venueName ?? '', street: ca.street ?? '', postalCode: ca.postalCode ?? '',
118+
locality: ca.locality ?? '', region: ca.region ?? '', country: ca.country ?? '',
119+
lat: ca.lat != null ? String(ca.lat) : '', lng: ca.lng != null ? String(ca.lng) : '',
120+
})
99121

100122
// Separate top-level items (with children already included from API)
101123
const topLevel = menuData.filter((item) => !item.parentId)
@@ -116,11 +138,23 @@ export default function SetupPage() {
116138
setSaved(false)
117139
setError('')
118140
try {
141+
const socialLinks = socialLinksText.split('\n').map((s) => s.trim()).filter(Boolean)
142+
const contactAddress = {
143+
venueName: address.venueName.trim() || undefined,
144+
street: address.street.trim() || undefined,
145+
postalCode: address.postalCode.trim() || undefined,
146+
locality: address.locality.trim() || undefined,
147+
region: address.region.trim() || undefined,
148+
country: address.country.trim() || undefined,
149+
lat: address.lat.trim() ? Number(address.lat) : undefined,
150+
lng: address.lng.trim() ? Number(address.lng) : undefined,
151+
}
119152
const res = await fetch('/api/admin/settings', {
120153
method: 'PUT',
121154
headers: { 'Content-Type': 'application/json' },
122155
body: JSON.stringify({
123156
siteName, logoUrl, backgroundImage, darkMode, themeSlug, logoMode, openaiModel, maintenanceMode,
157+
socialLinks, contactAddress,
124158
...(openaiApiKey ? { openaiApiKey } : {}),
125159
}),
126160
})
@@ -485,6 +519,69 @@ export default function SetupPage() {
485519
</div>
486520
</div>
487521

522+
{/* Contact & Social Media (structured data / JSON-LD) */}
523+
<div className="glass rounded-2xl p-6">
524+
<h2 className="text-lg font-display font-bold text-brand-text mb-1">
525+
Kontakt & Social Media
526+
</h2>
527+
<p className="text-sm text-brand-text-muted mb-4">
528+
Fließt in die strukturierten Daten (JSON-LD) für Google &amp; Co. — Veranstaltungsort und Social-Profile des Festivals.
529+
</p>
530+
531+
<div className="space-y-4">
532+
<div>
533+
<label className="block text-xs font-medium text-brand-text-muted mb-1">
534+
Social-Media-Profile
535+
</label>
536+
<textarea
537+
value={socialLinksText}
538+
onChange={(e) => setSocialLinksText(e.target.value)}
539+
rows={3}
540+
className="input-glass w-full text-sm font-mono"
541+
placeholder={'https://www.facebook.com/...\nhttps://www.instagram.com/e.ventschau/'}
542+
/>
543+
<p className="text-[11px] text-brand-text-muted mt-1.5">
544+
Eine URL pro Zeile (Facebook, Instagram, YouTube …). Erscheint als „sameAs“ in den strukturierten Daten.
545+
</p>
546+
</div>
547+
548+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
549+
<div className="sm:col-span-2">
550+
<label className="block text-xs font-medium text-brand-text-muted mb-1">Veranstaltungsort</label>
551+
<input type="text" value={address.venueName} onChange={(e) => setAddress({ ...address, venueName: e.target.value })} className="input-glass w-full text-sm" placeholder="Hof Thiele" />
552+
</div>
553+
<div className="sm:col-span-2">
554+
<label className="block text-xs font-medium text-brand-text-muted mb-1">Straße &amp; Nr.</label>
555+
<input type="text" value={address.street} onChange={(e) => setAddress({ ...address, street: e.target.value })} className="input-glass w-full text-sm" placeholder="Am Bruch 1-2" />
556+
</div>
557+
<div>
558+
<label className="block text-xs font-medium text-brand-text-muted mb-1">PLZ</label>
559+
<input type="text" value={address.postalCode} onChange={(e) => setAddress({ ...address, postalCode: e.target.value })} className="input-glass w-full text-sm" placeholder="21371" />
560+
</div>
561+
<div>
562+
<label className="block text-xs font-medium text-brand-text-muted mb-1">Ort</label>
563+
<input type="text" value={address.locality} onChange={(e) => setAddress({ ...address, locality: e.target.value })} className="input-glass w-full text-sm" placeholder="Tosterglope-Ventschau" />
564+
</div>
565+
<div>
566+
<label className="block text-xs font-medium text-brand-text-muted mb-1">Bundesland</label>
567+
<input type="text" value={address.region} onChange={(e) => setAddress({ ...address, region: e.target.value })} className="input-glass w-full text-sm" placeholder="Niedersachsen" />
568+
</div>
569+
<div>
570+
<label className="block text-xs font-medium text-brand-text-muted mb-1">Land (ISO)</label>
571+
<input type="text" value={address.country} onChange={(e) => setAddress({ ...address, country: e.target.value })} className="input-glass w-full text-sm" placeholder="DE" />
572+
</div>
573+
<div>
574+
<label className="block text-xs font-medium text-brand-text-muted mb-1">Breitengrad (lat)</label>
575+
<input type="text" inputMode="decimal" value={address.lat} onChange={(e) => setAddress({ ...address, lat: e.target.value })} className="input-glass w-full text-sm font-mono" placeholder="53.207676" />
576+
</div>
577+
<div>
578+
<label className="block text-xs font-medium text-brand-text-muted mb-1">Längengrad (lng)</label>
579+
<input type="text" inputMode="decimal" value={address.lng} onChange={(e) => setAddress({ ...address, lng: e.target.value })} className="input-glass w-full text-sm font-mono" placeholder="10.859330" />
580+
</div>
581+
</div>
582+
</div>
583+
</div>
584+
488585
{/* Background Image */}
489586
<div className="glass rounded-2xl p-6">
490587
<h2 className="text-lg font-display font-bold text-brand-text mb-1">

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ export async function PUT(request: Request) {
6969
: null
7070
}
7171

72+
// socialLinks (string[]) and contactAddress (object) are free-form JSON columns.
73+
// Send [] / {} to clear them; the read path normalizes junk away.
74+
const socialLinksUpdate =
75+
body.socialLinks !== undefined
76+
? { socialLinks: Array.isArray(body.socialLinks) ? body.socialLinks : null }
77+
: {}
78+
const contactAddressUpdate =
79+
body.contactAddress !== undefined
80+
? { contactAddress: body.contactAddress && typeof body.contactAddress === 'object' ? body.contactAddress : null }
81+
: {}
82+
7283
const settings = await prisma.siteSettings.upsert({
7384
where: { tenantId: tenant.id },
7485
update: {
@@ -81,6 +92,8 @@ export async function PUT(request: Request) {
8192
openaiModel: body.openaiModel ?? undefined,
8293
maintenanceMode: body.maintenanceMode !== undefined ? body.maintenanceMode : undefined,
8394
...openaiKeyUpdate,
95+
...socialLinksUpdate,
96+
...contactAddressUpdate,
8497
},
8598
create: {
8699
tenantId: tenant.id,
@@ -93,6 +106,8 @@ export async function PUT(request: Request) {
93106
openaiModel: body.openaiModel ?? 'auto',
94107
maintenanceMode: body.maintenanceMode ?? false,
95108
...openaiKeyUpdate,
109+
...socialLinksUpdate,
110+
...contactAddressUpdate,
96111
},
97112
})
98113

src/app/layout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Metadata } from 'next'
22
import { Inter, Playfair_Display, Space_Grotesk, IBM_Plex_Mono } from 'next/font/google'
33
import JsonLd from '@/components/JsonLd'
4-
import { organizationJsonLd } from '@/lib/seo'
4+
import { getOrganizationJsonLd } from '@/lib/seo'
55
import { getSiteSettings } from '@/lib/menu'
66
import { getTheme, themeToStyleString } from '@/lib/themes'
77
import './globals.css'
@@ -75,7 +75,7 @@ export default async function RootLayout({
7575
}: {
7676
children: React.ReactNode
7777
}) {
78-
const settings = await getSiteSettings()
78+
const [settings, organizationLd] = await Promise.all([getSiteSettings(), getOrganizationJsonLd()])
7979
// NEXT_PUBLIC_THEME_OVERRIDE lets a preview deploy force a theme without
8080
// mutating the shared production DB (e.g. previewing 'noir' before go-live).
8181
const theme = getTheme(process.env.NEXT_PUBLIC_THEME_OVERRIDE || settings.themeSlug || 'eventschau')
@@ -94,7 +94,7 @@ export default async function RootLayout({
9494
suppressHydrationWarning
9595
>
9696
<head>
97-
<JsonLd data={organizationJsonLd} />
97+
<JsonLd data={organizationLd} />
9898
<style dangerouslySetInnerHTML={{ __html: themeStyle }} />
9999
{settings.faviconUrl && <link rel="icon" href={settings.faviconUrl} />}
100100
<script

src/lib/__tests__/seo.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test } from 'node:test'
22
import assert from 'node:assert/strict'
3-
import { buildMetadata } from '../seo'
3+
import { buildMetadata, buildMusicFestivalJsonLd } from '../seo'
44

55
// buildMetadata must emit alternates.canonical so tracking-param variants
66
// (?utm_*, ?fbclid) collapse to a single indexable URL in Search Console.
@@ -16,3 +16,75 @@ test('buildMetadata canonical matches the OpenGraph url', () => {
1616
const md = buildMetadata(null, '/events/foo', { title: 'Foo', description: 'x' })
1717
assert.equal(String(md.alternates?.canonical ?? ''), String(md.openGraph?.url ?? ''))
1818
})
19+
20+
// ── MusicFestival JSON-LD (homepage entity) ────────────────────────────────
21+
const festivalInput = {
22+
siteUrl: 'https://e-ventschau.de',
23+
name: 'e-Ventschau',
24+
description: 'Benefiz-Festival',
25+
image: 'https://res.cloudinary.com/x/logo.png',
26+
organizerName: 'e-Ventschau e. V.',
27+
sameAs: ['https://www.facebook.com/groups/436038379848640/', 'https://www.instagram.com/e.ventschau/'],
28+
address: {
29+
venueName: 'Hof Thiele',
30+
street: 'Am Bruch 1-2',
31+
postalCode: '21371',
32+
locality: 'Tosterglope-Ventschau',
33+
region: 'Niedersachsen',
34+
country: 'DE',
35+
lat: 53.207676,
36+
lng: 10.85933,
37+
},
38+
featured: {
39+
title: 'e-Ventschau 2026',
40+
slug: 'e-ventschau-2026',
41+
startDate: new Date('2026-08-07T16:00:00.000Z'),
42+
endDate: new Date('2026-08-08T22:00:00.000Z'),
43+
locationName: 'Hof Thiele',
44+
},
45+
editions: [
46+
{ title: 'e-Ventschau 2026', slug: 'e-ventschau-2026', startDate: new Date('2026-08-07T16:00:00.000Z'), endDate: new Date('2026-08-08T22:00:00.000Z') },
47+
{ title: 'e-Ventschau 2025', slug: 'e-ventschau-2025', startDate: new Date('2025-08-08T16:00:00.000Z'), endDate: null },
48+
],
49+
}
50+
51+
test('buildMusicFestivalJsonLd returns null when there is no featured edition to anchor dates', () => {
52+
assert.equal(buildMusicFestivalJsonLd({ ...festivalInput, featured: null }), null)
53+
})
54+
55+
test('buildMusicFestivalJsonLd builds a MusicFestival with Place, geo, organizer, subEvents and sameAs', () => {
56+
const ld: any = buildMusicFestivalJsonLd(festivalInput)
57+
assert.equal(ld['@type'], 'MusicFestival')
58+
assert.equal(ld.url, 'https://e-ventschau.de/')
59+
assert.equal(ld.startDate, '2026-08-07T16:00:00.000Z')
60+
assert.equal(ld.endDate, '2026-08-08T22:00:00.000Z')
61+
assert.equal(ld.eventAttendanceMode, 'https://schema.org/OfflineEventAttendanceMode')
62+
assert.equal(ld.location['@type'], 'Place')
63+
assert.equal(ld.location.name, 'Hof Thiele')
64+
assert.equal(ld.location.address.streetAddress, 'Am Bruch 1-2')
65+
assert.equal(ld.location.address.addressLocality, 'Tosterglope-Ventschau')
66+
assert.equal(ld.location.address.addressCountry, 'DE')
67+
assert.equal(ld.location.geo.latitude, 53.207676)
68+
assert.equal(ld.location.geo.longitude, 10.85933)
69+
assert.equal(ld.organizer.name, 'e-Ventschau e. V.')
70+
assert.equal(ld.subEvent.length, 2)
71+
assert.equal(ld.subEvent[0].url, 'https://e-ventschau.de/events/e-ventschau-2026')
72+
assert.equal(ld.subEvent[1].endDate, undefined) // null endDate is omitted, not serialized
73+
assert.deepEqual(ld.sameAs, festivalInput.sameAs)
74+
})
75+
76+
test('buildMusicFestivalJsonLd omits location entirely when there is no address and no fallback name', () => {
77+
const ld: any = buildMusicFestivalJsonLd({
78+
...festivalInput,
79+
address: null,
80+
featured: { ...festivalInput.featured, locationName: null },
81+
})
82+
assert.equal('location' in ld, false)
83+
})
84+
85+
test('buildMusicFestivalJsonLd falls back to featured.locationName for the Place name', () => {
86+
const ld: any = buildMusicFestivalJsonLd({ ...festivalInput, address: null })
87+
assert.equal(ld.location.name, 'Hof Thiele')
88+
assert.equal('address' in ld.location, false)
89+
assert.equal('geo' in ld.location, false)
90+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test } from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import { normalizeSocialLinks, normalizeContactAddress } from '../settings-normalize'
4+
5+
// socialLinks is a free-form JSON column; only clean, non-empty URL strings survive.
6+
test('normalizeSocialLinks keeps trimmed non-empty strings and drops junk', () => {
7+
assert.deepEqual(
8+
normalizeSocialLinks([' https://a ', '', 42, null, 'https://b']),
9+
['https://a', 'https://b'],
10+
)
11+
})
12+
13+
test('normalizeSocialLinks returns [] for anything that is not an array', () => {
14+
assert.deepEqual(normalizeSocialLinks(null), [])
15+
assert.deepEqual(normalizeSocialLinks('https://x'), [])
16+
assert.deepEqual(normalizeSocialLinks(undefined), [])
17+
})
18+
19+
// contactAddress is a free-form JSON column; strings are trimmed, geo is coerced to
20+
// finite numbers (the admin form submits them as text), empty fields are dropped.
21+
test('normalizeContactAddress trims strings, coerces numeric geo, drops empties', () => {
22+
const a = normalizeContactAddress({
23+
venueName: ' Hof Thiele ',
24+
street: ' Am Bruch 1-2 ',
25+
postalCode: '21371',
26+
locality: 'Tosterglope-Ventschau',
27+
region: '',
28+
country: '',
29+
lat: '53.207676',
30+
lng: '10.85933',
31+
})
32+
assert.equal(a?.venueName, 'Hof Thiele')
33+
assert.equal(a?.street, 'Am Bruch 1-2')
34+
assert.equal(a?.postalCode, '21371')
35+
assert.equal(a?.locality, 'Tosterglope-Ventschau')
36+
assert.equal(a?.region, undefined)
37+
assert.equal(a?.country, undefined)
38+
assert.equal(a?.lat, 53.207676)
39+
assert.equal(a?.lng, 10.85933)
40+
})
41+
42+
test('normalizeContactAddress returns null when nothing usable is present', () => {
43+
assert.equal(normalizeContactAddress({ street: '', lat: '', lng: 'abc' }), null)
44+
assert.equal(normalizeContactAddress(null), null)
45+
assert.equal(normalizeContactAddress('nope'), null)
46+
})

0 commit comments

Comments
 (0)