Veranstaltung: Detailseite mit Zuständen
Event-Seite mit Faktenleiste, Platzbox (Restplätze, Stepper je Preiskategorie, Summe, Reservierung ohne Online-Zahlung), .ics-Download, Event-JSON-LD und sechs Zuständen von frei bis abgesagt.
Konzert, Theater, Lesung oder Führung mit Eintritt und begrenzten Plätzen, bezahlt an der Abendkasse oder im eigenen Shop.
Die Seite für einen einzelnen Termin. Oben Sparte, Datum, Titel und eine Faktenleiste: Tag, Einlass, Beginn, Ende, Ort, Preis, Dauer, Alter. Daneben die Platzbox mit Restplätzen, Preiskategorien mit Stepper, Summe und „Verbindlich reservieren“ (oder bei freiem Eintritt eine Anmeldung). „In den Kalender“ lädt eine .ics-Datei. Darunter Beschreibung, Mitwirkende, Anfahrt, Barrierefreiheit und weitere Termine. Die Seite kennt sechs Zustände: Plätze frei, wenige Plätze, ausgebucht mit Warteliste, abgesagt, verschoben und vergangen. Jeder Zustand ändert Platzbox, Hinweiszeile und Event-JSON-LD (eventStatus, availability), Datum und Ort bleiben stehen.
Eine Event-Seite beantwortet fünf Fragen: wann, wo, was kostet es, gibt es noch Plätze, wie komme ich hin. Stehen sie in einer Leiste über dem Text, entscheidet man in Sekunden. Ehrliche Zustände sparen Anrufe: Ausgebucht bietet die Warteliste, Abgesagt erklärt die Erstattung, Verschoben zeigt alten und neuen Termin. Die .ics-Datei holt den Termin in den eigenen Kalender, ohne Konto bei einem Dienst.
Wann einsetzen
Kultur, Musik, Verein, Eventlocation, Freizeit, Buchhandlung mit Lesungen, Volkshochschule und Kursanbieter mit Einzelterminen. Immer dort, wo programm-kalender oder tourdaten-liste auf eine eigene Seite je Termin verlinken.
Wann nicht
Nicht für Serien mit festen Wochenterminen (dafür wochen-kursplan). Nicht, wenn der Ticketverkauf vollständig bei einem fremden Anbieter liegt und die Seite nur weiterleitet; dann reicht eine Zeile in der Liste mit externem Link. Keine Restplatz-Zahl erfinden oder künstlich verknappen: nur zeigen, was das Buchungssystem meldet.
Varianten
- Ticket: Preiskategorien mit Stepper, Summe, Reservierung mit Bezahlung an der Abendkasse, Restplätze als Balken
- Anmeldung: Eintritt frei, Personenzahl bis 2, Feld für Unterstützungsbedarf, Bestätigung per E-Mail
- Zustände: frei, wenige Plätze, ausgebucht (Warteliste), abgesagt (Erstattung), verschoben (alter und neuer Termin), vergangen
Umsetzung
Ein Datensatz je Termin (Titel, Sparte, Start, Einlass, Ende, Ort mit Adresse, Preise, Kapazität, Status) erzeugt Faktenleiste, Platzbox, .ics und JSON-LD. Datum mit Intl.DateTimeFormat('de-DE', {weekday: 'long', day: 'numeric', month: 'long'}) und Uhrzeit „20:00 Uhr“; im datetime-Attribut ISO 8601 mit +01:00. Platzbox als <form>: Preiskategorien mit Stepper (Knöpfe − und + mit aria-label, <output> für die Zahl), Höchstzahl aus den Restplätzen, Summe mit Intl.NumberFormat EUR. Restplätze als Text („Noch 23 von 140 Plätzen“) plus Balken aria-hidden. „In den Kalender“: VCALENDAR mit VEVENT (UID, DTSTAMP, DTSTART;TZID=Europe/Berlin, DTEND, SUMMARY, LOCATION, URL, STATUS) als Blob mit type text/calendar und download="….ics", Zeilenende CRLF. Zustände über data-zustand am <article>: ausgebucht ersetzt das Formular durch die Warteliste, abgesagt durch Erstattungshinweis und Kontakt, verschoben zeigt den alten Termin durchgestrichen mit <del>, vergangen zeigt Nachbericht und nächste Termine. Das JSON-LD (Event, eventStatus, offers.availability, location als Place mit PostalAddress, organizer) wird bei jedem Zustandswechsel neu geschrieben. Bezahlung an der Abendkasse; Online-Zahlung, Stornobedingungen und Erstattung legt der Veranstalter fest und lässt sie vom Kunden prüfen.
Robust umsetzen
Telefon. Faktenleiste als zweispaltige Liste direkt unter dem Titel, Platzbox folgt nach der Beschreibung. Eine feste Leiste unten zeigt Preis ab und „Plätze reservieren“ bzw. den Zustand und springt zur Platzbox. Stepper-Knöpfe 44 px.
Ohne Bewegung. Zustandswechsel und Balken ohne Übergang, Sprung zur Platzbox ohne weiches Scrollen.
Barrierefrei. Zustand als Text in der Hinweiszeile, nicht nur als Farbe. Stepper mit benannten Knöpfen und Ansage der Menge. Summe in aria-live="polite". Absage und Verschiebung stehen vor dem Formular und in der h1-Nähe. Datum und Uhrzeit als time. Anfahrt als Text mit Haltestelle und Fußweg, Karte nur zusätzlich. Barrierefreiheit des Orts als eigener Abschnitt.
Code
<!DOCTYPE html>
<!--
Veranstaltung: Detailseite mit Zuständen
Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/veranstaltung-detailseite/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de" data-v="ticket">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Veranstaltung: Detailseite mit Zuständen · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
/* Token aus kultur-programm-haltung (token-swiss-knackig) */
:root {
--grund: #efede9; --papier: #f7f6f3; --text: #0b0b0b; --leise: #4a4844; --linie: #0b0b0b; --linie-fein: #c9c5bd;
--sparte: #c4421a; --musik: #c4421a; --gespraech: #3d5f9a; --warn: #8a2c0d;
--h: 'Schibsted Grotesk', 'Arial Narrow', sans-serif; --t: 'Atkinson Hyperlegible Next', system-ui, sans-serif; --mono: 'IBM Plex Mono', ui-monospace, monospace;
--ein: cubic-bezier(0.25, 1, 0.5, 1); --aus: cubic-bezier(0.5, 0, 0.75, 0);
--ui: 150ms; --wechsel: 250ms; --balken: 350ms; --weg: 150ms;
}
:root[data-v="anmeldung"] { --sparte: var(--gespraech); }
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: var(--t); -webkit-font-smoothing: antialiased; scroll-padding-top: 24px; }
:root:has(:focus-visible) { scroll-behavior: auto; }
body { font-size: 1.125rem; line-height: 1.6; min-height: 100svh; }
a { color: inherit; text-underline-offset: .18em; }
button, input, textarea { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; }
:focus-visible { outline: 3px solid var(--text); outline-offset: 3px; }
.vh { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.huelle { max-width: 1600px; margin: 0 auto; padding: 0 clamp(16px, 3vw, 40px); }
.mono { font-family: var(--mono); font-size: .8125rem; letter-spacing: .04em; font-variant-numeric: tabular-nums; }
.ziffern { font-variant-numeric: tabular-nums lining-nums; }
h1, h2, h3 { font-family: var(--h); text-wrap: balance; }
p { text-wrap: pretty; hyphens: auto; }
/* Kopf */
.kopf { border-bottom: 1px solid var(--linie); background: var(--grund); }
.kopf .huelle { display: flex; align-items: center; gap: 32px; min-height: 64px; }
.marke { display: flex; align-items: baseline; gap: 10px; margin-right: auto; text-decoration: none; }
.marke b { font: 900 1.625rem/1 var(--h); letter-spacing: -.02em; text-transform: uppercase; }
.marke span { font-size: .8125rem; color: var(--leise); }
.nav ul { display: flex; gap: 26px; list-style: none; font-family: var(--h); font-weight: 600; font-size: 1rem; }
.nav a { text-decoration: none; }
.nav a:hover, .nav a[aria-current] { text-decoration: underline; text-decoration-thickness: 2px; }
.krumen { padding: 16px 0 0; }
.krumen ol { list-style: none; display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: .875rem; color: var(--leise); }
.krumen li + li::before { content: '→'; margin-right: 10px; }
/* Hinweiszeile für Zustände */
.hinweis { display: none; border-bottom: 1px solid var(--linie); }
.hinweis p { padding: 14px 0; display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; font-weight: 700; }
.hinweis p span { font-weight: 400; }
.hinweis .marke-z { font-family: var(--mono); font-size: .8125rem; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; padding: 3px 8px; border: 1px solid currentColor; }
[data-zustand="knapp"] .hinweis, [data-zustand="ausgebucht"] .hinweis, [data-zustand="abgesagt"] .hinweis, [data-zustand="verschoben"] .hinweis, [data-zustand="vergangen"] .hinweis { display: block; }
[data-zustand="abgesagt"] .hinweis { background: var(--text); color: var(--grund); }
[data-zustand="abgesagt"] .hinweis :focus-visible { outline-color: var(--grund); }
[data-zustand="verschoben"] .hinweis, [data-zustand="ausgebucht"] .hinweis { background: var(--papier); }
[data-zustand="knapp"] .hinweis { background: var(--papier); }
/* Kopf der Veranstaltung */
.buehne { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0 clamp(16px, 2vw, 24px); padding: clamp(28px, 4vw, 56px) 0 0; }
.titelblock { grid-column: 1 / span 8; }
.spartezeile { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
.sparte { display: inline-block; background: var(--sparte); color: #fff; font: 700 .875rem/1 var(--h); padding: 7px 10px 6px; text-transform: uppercase; letter-spacing: .04em; }
.datumzeile { font-family: var(--mono); font-size: clamp(.9375rem, .85rem + .3vw, 1.0625rem); letter-spacing: .04em; text-transform: uppercase; }
.datumzeile del { color: var(--leise); text-decoration-thickness: 2px; }
.datumzeile ins { text-decoration: none; background: var(--text); color: var(--grund); padding: 1px 6px; }
h1.titel { font-weight: 900; font-size: clamp(2.75rem, 1.4rem + 6vw, 7rem); line-height: .96; letter-spacing: -.03em; margin-top: 18px; }
[data-zustand="abgesagt"] h1.titel { color: var(--leise); }
.untertitel { font-family: var(--h); font-weight: 500; font-size: clamp(1.25rem, 1rem + 1vw, 1.875rem); line-height: 1.2; margin-top: 16px; max-width: 32ch; }
.bild { grid-column: 1 / span 8; margin-top: clamp(24px, 3vw, 40px); }
.bild .rahmen { aspect-ratio: 16 / 9; overflow: hidden; background: #3a2c22; }
.bild img { width: 100%; height: 100%; object-fit: cover; display: block; }
[data-zustand="abgesagt"] .bild img, [data-zustand="vergangen"] .bild img { filter: grayscale(1) contrast(.9); }
.bild figcaption { font-size: .8125rem; color: var(--leise); margin-top: 8px; }
/* Faktenleiste */
.fakten { grid-column: 1 / span 8; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--linie); margin-top: clamp(24px, 3vw, 40px); }
.fakten div { padding: 14px 16px 16px 0; border-bottom: 1px solid var(--linie-fein); }
.fakten dt { font-family: var(--mono); font-size: .75rem; letter-spacing: .06em; text-transform: uppercase; color: var(--leise); }
.fakten dd { font-family: var(--h); font-weight: 700; font-size: 1.125rem; line-height: 1.3; margin-top: 4px; font-variant-numeric: lining-nums; }
.fakten dd small { display: block; font-family: var(--t); font-weight: 400; font-size: .875rem; color: var(--leise); }
/* Inhalt */
.inhalt { grid-column: 1 / span 8; }
.abschnitt { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 6fr); gap: 0 clamp(16px, 2vw, 24px); padding: clamp(32px, 4vw, 56px) 0; border-bottom: 1px solid var(--linie); }
.abschnitt h2 { font-weight: 800; font-size: 1.375rem; line-height: 1.15; }
.abschnitt .text > * + * { margin-top: .9em; }
.abschnitt .text { max-width: 68ch; }
.leute { list-style: none; display: grid; gap: 0; border-top: 1px solid var(--linie-fein); }
.leute li { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--linie-fein); }
.leute b { font-family: var(--h); font-weight: 700; }
.weg { list-style: none; display: grid; gap: 10px; }
.weg li { display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); gap: 12px; }
.weg .mono { padding-top: .2em; color: var(--leise); text-transform: uppercase; }
.termine { list-style: none; border-top: 1px solid var(--linie); }
.termine li { position: relative; display: grid; grid-template-columns: 12.5rem minmax(0, 1fr) auto; gap: 6px 20px; align-items: baseline; padding: 14px 0; border-bottom: 1px solid var(--linie-fein); }
.termine a { font-family: var(--h); font-weight: 700; font-size: 1.25rem; text-decoration: none; line-height: 1.2; }
.termine a::after { content: ''; position: absolute; inset: 0; }
.termine li:hover a { text-decoration: underline; text-decoration-thickness: 2px; }
.termine a:focus-visible { outline: none; }
.termine li:has(a:focus-visible) { outline: 3px solid var(--text); outline-offset: 2px; }
.termine .chip { font-family: var(--mono); font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border: 1px solid var(--text); }
/* Platzbox */
.box { grid-column: 9 / span 4; grid-row: 1 / span 6; align-self: start; position: sticky; top: 20px; background: var(--papier); border: 1px solid var(--linie); }
.box > header { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--linie); }
.box > header h2 { font-weight: 800; font-size: 1.25rem; }
.box .koerper { padding: 18px; }
.box .koerper > * + * { margin-top: 16px; }
.box .einblenden > * + * { margin-top: 16px; }
.box .einblenden { animation: einbl var(--wechsel) var(--ein) both; }
@keyframes einbl { from { opacity: 0; transform: translateY(8px); } }
.rest { display: grid; gap: 8px; }
.rest p { display: flex; justify-content: space-between; gap: 12px; font-weight: 700; }
.rest p span { font-weight: 400; color: var(--leise); }
.rest .balken { height: 10px; border: 1px solid var(--linie); background: repeating-linear-gradient(90deg, transparent 0 calc(100% / 28 - 1px), rgba(11, 11, 11, .14) calc(100% / 28 - 1px) calc(100% / 28)); position: relative; overflow: hidden; }
.rest .balken b { position: absolute; inset: 0; background: var(--text); transform-origin: 0 50%; transform: scaleX(var(--belegt, 0)); transition: transform var(--balken) var(--ein); }
[data-zustand="knapp"] .rest p { color: var(--warn); }
.kat { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: center; padding: 12px 0; border-top: 1px solid var(--linie-fein); }
.kat:last-of-type { border-bottom: 1px solid var(--linie-fein); }
.kat b { font-family: var(--h); font-weight: 700; }
.kat small { grid-column: 1; font-size: .8125rem; color: var(--leise); line-height: 1.35; }
.kat .preis { font-family: var(--mono); font-size: .875rem; }
.stepper { grid-column: 2; grid-row: 1 / span 2; display: inline-grid; grid-template-columns: 40px 2.2em 40px; align-items: center; border: 1px solid var(--linie); background: #fff; }
.stepper button { height: 40px; font: 700 1.25rem/1 var(--h); transition: background-color var(--ui) var(--ein); }
.stepper button:hover:not(:disabled) { background: var(--grund); }
.stepper button:disabled { color: #9a958c; cursor: not-allowed; }
.stepper output { text-align: center; font-family: var(--mono); font-size: 1rem; border-left: 1px solid var(--linie-fein); border-right: 1px solid var(--linie-fein); line-height: 40px; }
.summe { display: flex; justify-content: space-between; align-items: baseline; font-family: var(--h); font-weight: 800; font-size: 1.375rem; }
.summe span { font-family: var(--t); font-weight: 400; font-size: .875rem; color: var(--leise); }
.feld { display: grid; gap: 4px; }
.feld label { font-size: .875rem; font-weight: 700; }
.feld input, .feld textarea { border: 1px solid var(--linie); background: #fff; padding: 10px 12px; font-size: 1rem; border-radius: 0; width: 100%; }
.feld textarea { min-height: 70px; resize: vertical; }
.feld .fehler { font-size: .8125rem; color: var(--warn); font-weight: 700; }
.feld input[aria-invalid="true"] { border-color: var(--warn); border-width: 2px; }
.haupt { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 15px 18px; background: var(--text); color: var(--grund); font: 800 1.0625rem/1 var(--h); transition: background-color var(--ui) var(--ein); }
.haupt:hover { background: var(--sparte); }
.haupt:disabled { background: #7a766f; cursor: not-allowed; }
.klein { font-size: .8125rem; color: var(--leise); line-height: 1.45; }
.kalender { display: inline-flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--linie); font-weight: 700; font-size: .9375rem; position: relative; }
.kalender::before, .kalender::after { content: ''; position: absolute; width: 8px; height: 8px; border: 2px solid var(--text); transition: transform var(--ui) var(--ein); }
.kalender::before { top: -4px; left: -4px; border-right: 0; border-bottom: 0; }
.kalender::after { bottom: -4px; right: -4px; border-left: 0; border-top: 0; }
.kalender:hover::before { transform: translate(-3px, -3px); }
.kalender:hover::after { transform: translate(3px, 3px); }
.bestaetigt { border: 2px solid var(--text); padding: 16px; background: #fff; }
.bestaetigt h3 { font-weight: 800; font-size: 1.25rem; }
.bestaetigt dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 14px; margin-top: 10px; font-size: .9375rem; }
.bestaetigt dt { color: var(--leise); }
.bestaetigt dd { font-weight: 700; }
.box .zustandsblock h3 { font-weight: 800; font-size: 1.5rem; line-height: 1.1; }
/* Telefonleiste */
.leiste { display: none; }
.toast { position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 12px); z-index: 45; background: var(--text); color: var(--grund); padding: 12px 16px; font-size: .9375rem; opacity: 0; pointer-events: none; transition: opacity var(--weg) var(--aus), transform var(--weg) var(--aus); max-width: calc(100% - 32px); }
.toast[data-an] { opacity: 1; transform: translate(-50%, 0); transition: opacity var(--wechsel) var(--ein), transform var(--wechsel) var(--ein); }
/* Fuß */
.fuss { margin-top: clamp(56px, 7vw, 112px); border-top: 1px solid var(--linie); padding: 32px 0 40px; font-size: .9375rem; }
.fuss .reihe { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 24px; }
.fuss b { font: 900 2rem/1 var(--h); text-transform: uppercase; letter-spacing: -.02em; display: block; margin-bottom: 8px; }
.fuss ul { list-style: none; display: grid; gap: 4px; }
.fuss a { text-decoration: none; }
.fuss a:hover { text-decoration: underline; }
/* Pult */
.pult { position: fixed; left: 16px; bottom: 16px; z-index: 40; width: 236px; background: #fff; border: 1px solid var(--linie); padding: 12px; display: grid; gap: 8px; font-size: 13px; }
.pult .m { font: 500 10.5px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.pult .reihe { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--linie); }
.pult .reihe.drei { grid-template-columns: 1fr 1fr 1fr; }
.pult .reihe button { padding: 7px 4px; font-weight: 600; font-size: 12.5px; }
.pult .reihe.drei button { border-top: 1px solid var(--linie-fein); }
.pult .reihe.drei button:nth-child(-n+3) { border-top: 0; }
.pult .reihe button[aria-pressed="true"] { background: var(--text); color: var(--grund); }
.pult .einzeln { border: 1px solid var(--linie); padding: 7px 10px; text-align: left; font-weight: 600; }
.pult .werte { font: 400 10.5px/1.5 'Geist Mono', monospace; color: var(--leise); }
dialog.ld { margin: auto; width: min(720px, calc(100% - 32px)); max-height: min(80vh, 720px); border: 1px solid var(--linie); padding: 0; background: #fff; }
dialog.ld::backdrop { background: rgba(11, 11, 11, .45); }
dialog.ld header { display: flex; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid var(--linie); position: sticky; top: 0; background: #fff; }
dialog.ld h2 { font-size: 1.25rem; }
dialog.ld header button { border: 1px solid var(--linie); padding: 6px 12px; font-weight: 700; align-self: start; }
dialog.ld pre { padding: 16px 18px; font: 400 12px/1.55 'Geist Mono', monospace; white-space: pre-wrap; word-break: break-word; }
.zeiger { position: fixed; left: 0; top: 0; width: 22px; height: 22px; z-index: 60; pointer-events: none; opacity: 0; transition: opacity 200ms var(--ein); }
.vorfuehrung .zeiger { opacity: 1; }
.zeiger.drueckt { scale: .86; transform-origin: 3px 2px; }
.vorschau body { overflow: hidden; }
.vorschau .pult, .vorschau .leiste { display: none !important; }
.menueknopf { display: none; font: 700 1rem/1 var(--h); padding: 10px 0; }
.kopf { position: relative; z-index: 20; }
@media (max-width: 1100px) {
.nav { display: none; } .menueknopf { display: inline-block; }
.nav[data-offen] { display: block; position: absolute; top: 100%; left: 0; right: 0; background: var(--grund); border-bottom: 1px solid var(--linie); padding: 8px clamp(16px, 3vw, 40px) 16px; }
.nav[data-offen] ul { flex-direction: column; gap: 6px; font-size: 1.25rem; }
.titelblock, .bild, .fakten, .inhalt { grid-column: 1 / -1; }
.box { grid-column: 1 / -1; grid-row: auto; position: static; margin-top: 32px; max-width: 560px; }
.buehne > .box { order: 5; }
.fakten { order: 3; } .bild { order: 2; } .titelblock { order: 1; } .inhalt { order: 6; }
}
@media (max-width: 760px) {
.fakten { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.abschnitt { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.termine li { grid-template-columns: minmax(0, 1fr); gap: 2px; }
.weg li { grid-template-columns: minmax(0, 1fr); gap: 0; }
.fuss .reihe { grid-template-columns: minmax(0, 1fr); }
.leiste { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); background: var(--grund); border-top: 1px solid var(--linie); }
.leiste p { font-size: .875rem; line-height: 1.25; }
.leiste p b { display: block; font-family: var(--h); font-size: 1.0625rem; }
.leiste a { background: var(--text); color: var(--grund); text-decoration: none; font: 800 1rem/1 var(--h); padding: 14px 16px; }
body { padding-bottom: 76px; }
.pult { bottom: 84px; }
.toast { bottom: 88px; }
}
@media (max-width: 360px) { .stepper { grid-template-columns: 36px 2em 36px; } .marke span { display: none; } }
@media (hover: none) { .termine li:hover a { text-decoration: none; } .kalender:hover::before, .kalender:hover::after { transform: none; } }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
.rest .balken b { background: Highlight; }
}
</style>
</head>
<body>
<header class="kopf">
<div class="huelle">
<a class="marke" href="#" aria-label="Kulturhaus Sägewerk Rottweil, Startseite"><b>Sägewerk</b><span>Kulturhaus Rottweil</span></a>
<button class="menueknopf" type="button" aria-expanded="false" aria-controls="hauptmenue">Menü</button>
<nav class="nav" id="hauptmenue" aria-label="Hauptmenü"><ul><li><a href="#" aria-current="page">Programm</a></li><li><a href="#">Besuch</a></li><li><a href="#">Haus</a></li><li><a href="#">Mitmachen</a></li></ul></nav>
</div>
</header>
<main id="inhalt" data-zustand="offen">
<div class="hinweis" data-hinweis><div class="huelle"><p role="status" data-hinweis-text></p></div></div>
<div class="huelle">
<nav class="krumen" aria-label="Brotkrumen"><ol><li><a href="#">Programm</a></li><li><a href="#" data-ref="sparte-link"></a></li><li aria-current="page" data-ref="krume"></li></ol></nav>
<article class="buehne" aria-labelledby="titel">
<div class="titelblock">
<p class="spartezeile"><span class="sparte" data-ref="sparte"></span><span class="datumzeile" data-ref="datumzeile"></span></p>
<h1 class="titel" id="titel" data-ref="titel"></h1>
<p class="untertitel" data-ref="untertitel"></p>
</div>
<aside class="box" aria-labelledby="box-titel" data-box>
<header><h2 id="box-titel" data-ref="box-titel">Plätze</h2><span class="mono" data-ref="box-meta"></span></header>
<div class="koerper" data-box-inhalt></div>
</aside>
<figure class="bild">
<div class="rahmen"><img src="bilder/foto-hero-wortmarke-lautsprecher.webp" width="1600" height="1000" alt="Zwei Holzlautsprecher und ein Röhrenverstärker vor einem Plattenregal" data-ref="bild"></div>
<figcaption data-ref="bildtext"></figcaption>
</figure>
<dl class="fakten" data-fakten></dl>
<div class="inhalt">
<section class="abschnitt" aria-labelledby="h-abend"><h2 id="h-abend" data-ref="h-abend"></h2><div class="text" data-ref="text"></div></section>
<section class="abschnitt" aria-labelledby="h-leute"><h2 id="h-leute">Mitwirkende</h2><div class="text"><ul class="leute" data-ref="leute"></ul></div></section>
<section class="abschnitt" aria-labelledby="h-weg"><h2 id="h-weg">Anfahrt</h2><div class="text">
<ul class="weg">
<li><span class="mono">Adresse</span><span>Kulturhaus Sägewerk, Maschinenhalle<br>Am Sägewerk 4, 78628 Rottweil</span></li>
<li><span class="mono">Bus</span><span>Linie 3 bis „Neckarbrücke“, dann 4 Minuten zu Fuß bergab</span></li>
<li><span class="mono">Bahn</span><span>Bahnhof Rottweil, 15 Minuten zu Fuß</span></li>
<li><span class="mono">Auto</span><span>Parkplatz am Haus, 40 Plätze, davon 3 breite Plätze am Eingang</span></li>
</ul>
<p><a href="#">Karte öffnen</a> (lädt erst nach Klick einen Kartendienst)</p>
</div></section>
<section class="abschnitt" aria-labelledby="h-zugang"><h2 id="h-zugang">Barrierefreiheit</h2><div class="text">
<p>Die Maschinenhalle ist stufenlos erreichbar, ein WC für Rollstuhlnutzende liegt im Foyer. Es gibt sechs Rollstuhlplätze in der ersten Reihe und Plätze für Begleitpersonen daneben. Eine Höranlage gibt es in der Halle nicht.</p>
<p class="klein">Angaben vom Haus geprüft am 01.10.2026. Fragen zu Ihrem Besuch: 0741 000 00-20.</p>
</div></section>
<section class="abschnitt" aria-labelledby="h-mehr"><h2 id="h-mehr">Weitere Termine</h2><div class="text"><ol class="termine" data-ref="termine"></ol></div></section>
</div>
</article>
</div>
</main>
<div class="leiste" data-leiste><p data-leiste-text></p><a href="#box-titel" data-leiste-knopf>Plätze reservieren</a></div>
<div class="toast" role="status" aria-live="polite" data-toast></div>
<footer class="fuss">
<div class="huelle reihe">
<div><b>Sägewerk</b><p>Kulturhaus Sägewerk gGmbH · Am Sägewerk 4 · 78628 Rottweil</p></div>
<ul><li><a href="#">Programm</a></li><li><a href="#">Abendkasse und Reservierung</a></li><li><a href="#">Newsletter</a></li></ul>
<ul><li><a href="#">Impressum</a></li><li><a href="#">Datenschutz</a></li><li><a href="#">Barrierefreiheit</a></li></ul>
</div>
</footer>
<div class="pult" role="group" aria-label="Steuerung der Demo">
<span class="m">Variante</span>
<div class="reihe"><button type="button" data-v="ticket" aria-pressed="true">Ticket</button><button type="button" data-v="anmeldung" aria-pressed="false">Anmeldung</button></div>
<span class="m">Zustand</span>
<div class="reihe drei" data-zustaende>
<button type="button" data-z="offen" aria-pressed="true">frei</button><button type="button" data-z="knapp" aria-pressed="false">knapp</button><button type="button" data-z="ausgebucht" aria-pressed="false">voll</button>
<button type="button" data-z="abgesagt" aria-pressed="false">abgesagt</button><button type="button" data-z="verschoben" aria-pressed="false">verschoben</button><button type="button" data-z="vergangen" aria-pressed="false">vorbei</button>
</div>
<button type="button" class="einzeln" data-ld-knopf>JSON-LD zeigen { }</button>
<p class="werte" data-werte></p>
</div>
<dialog class="ld" aria-labelledby="ld-titel">
<header><div><h2 id="ld-titel">JSON-LD dieses Termins</h2><p class="klein">Ändert sich mit dem Zustand: eventStatus, availability, startDate.</p></div><button type="button" data-ld-zu>Schließen</button></header>
<pre data-ld-text></pre>
</dialog>
<svg class="zeiger" viewBox="0 0 22 22" aria-hidden="true" data-zeiger><path d="M3 2 L3 18 L7.5 14 L10.5 20.5 L13.2 19.3 L10.3 13 L16.5 13 Z" fill="#141413" stroke="#fff" stroke-width="1.4" stroke-linejoin="round"/></svg>
<script type="application/ld+json" id="ld"></script>
<script>
(() => {
const html = document.documentElement, main = document.querySelector('main');
const reduziert = () => html.classList.contains('reduziert');
const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
const ref = n => $(`[data-ref="${n}"]`);
const eur = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
const tagLang = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Berlin' });
const tagKurz = new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'Europe/Berlin' });
const uhr = d => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' }).format(d) + ' Uhr';
const ORT = { name: 'Kulturhaus Sägewerk', raum: 'Maschinenhalle', strasse: 'Am Sägewerk 4', plz: '78628', stadt: 'Rottweil' };
const MAX = 6;
// Ein Datensatz je Termin erzeugt Seite, Platzbox, .ics und JSON-LD
const TERMINE = {
ticket: {
slug: 'raum-aus-rauschen', sparte: 'Musik', titel: 'Raum aus Rauschen', untertitel: 'Das Ensemble Spulwerk spielt acht Tonbandmaschinen.',
start: '2026-11-27T20:00:00+01:00', einlass: '2026-11-27T19:00:00+01:00', ende: '2026-11-27T21:30:00+01:00', neu: '2027-01-16T20:00:00+01:00', neuEinlass: '2027-01-16T19:00:00+01:00', neuEnde: '2027-01-16T21:30:00+01:00',
kapazitaet: 140, rest: { offen: 23, knapp: 9, ausgebucht: 0 },
preise: [{ id: 'normal', name: 'Normal', preis: 18 }, { id: 'erm', name: 'Ermäßigt', preis: 12, info: 'Schüler, Studierende, Schwerbehinderte ab 50 (mit Nachweis)' }, { id: 'u18', name: 'Unter 18', preis: 0, info: 'frei, Platz trotzdem reservieren' }],
dauer: 'ca. 90 Min., ohne Pause', alter: 'ab 12 Jahren', sprache: 'ohne Sprache', bildtext: 'Foto: Kulturhaus Sägewerk (Platzhalter). Im Einsatz: Pressefoto des Ensembles mit Bildnachweis.',
hAbend: 'Über den Abend',
text: ['Acht Tonbandmaschinen, acht Schleifen, ein Raum. Das Ensemble Spulwerk aus Freiburg spielt mit Bändern, die es in den letzten zwei Jahren an Orten rund um Rottweil aufgenommen hat: in der Neckarschlucht, im Viadukt, im leeren Freibad.', 'Die Schleifen laufen unterschiedlich schnell und verschieben sich langsam gegeneinander. Im Lauf des Abends entsteht so ein Klangraum, der sich nie wiederholt. Wer möchte, kann sich während des Konzerts frei in der Halle bewegen.', 'Hinweis: Das Konzert ist zeitweise laut. Gehörschutz liegt am Eingang aus.'],
leute: [['Spulwerk', 'Ensemble'], ['Mara Lenhardt', 'Bandmaschinen, Konzept'], ['Ole Pfisterer', 'Bandmaschinen, Klangregie'], ['Hannes Ebert', 'Licht']],
termine: [['2026-12-04T20:00:00+01:00', 'Nachtkino: Filme ohne Ton, live vertont', 'Film'], ['2026-12-11T19:30:00+01:00', 'Lesung: Die Uhrmacherin von Schramberg', 'Literatur'], ['2026-12-18T20:00:00+01:00', 'Chor ohne Noten', 'Musik']],
werte: 'Ein 0.25,1,0.5,1 · Aus 0.5,0,0.75,0 · UI 150 ms · Box 250 ms, 8 px · Balken 350 ms · knapp ab 10 % · max. 6 je Reservierung · ics CRLF, TZID Europe/Berlin'
},
anmeldung: {
slug: 'wie-klingt-ein-raum', sparte: 'Gespräch', titel: 'Wie klingt ein Raum?', untertitel: 'Werkstattgespräch mit dem Ensemble Spulwerk. Eintritt frei, Anmeldung erbeten.',
start: '2026-11-26T18:00:00+01:00', einlass: '2026-11-26T17:45:00+01:00', ende: '2026-11-26T19:30:00+01:00', neu: '2027-01-15T18:00:00+01:00', neuEinlass: '2027-01-15T17:45:00+01:00', neuEnde: '2027-01-15T19:30:00+01:00',
kapazitaet: 30, rest: { offen: 12, knapp: 3, ausgebucht: 0 }, frei: true,
preise: [{ id: 'person', name: 'Personen', preis: 0, info: 'höchstens 2 je Anmeldung' }],
dauer: 'ca. 90 Min.', alter: 'ab 14 Jahren', sprache: 'Deutsch', bildtext: 'Foto: Kulturhaus Sägewerk (Platzhalter).',
hAbend: 'Worum es geht',
text: ['Am Abend vor dem Konzert öffnet das Ensemble seine Werkstatt im Foyer. Sie sehen, wie Bandschleifen geschnitten und geklebt werden, und hören Aufnahmen aus der Neckarschlucht im Rohzustand.', 'Danach Gespräch mit Mara Lenhardt und Ole Pfisterer über Aufnahmeorte, Technik und die Frage, wann ein Geräusch Musik wird. Fragen aus dem Publikum sind ausdrücklich erwünscht.'],
leute: [['Mara Lenhardt', 'Spulwerk'], ['Ole Pfisterer', 'Spulwerk'], ['Ines Haug', 'Moderation, Kulturhaus Sägewerk']],
termine: [['2026-11-27T20:00:00+01:00', 'Raum aus Rauschen', 'Musik'], ['2026-12-03T18:00:00+01:00', 'Werkstattgespräch: Licht als Partitur', 'Gespräch'], ['2026-12-11T19:30:00+01:00', 'Lesung: Die Uhrmacherin von Schramberg', 'Literatur']],
werte: 'wie Ticket · Eintritt frei · Personen 1 bis 2 · Feld Unterstützungsbedarf · JSON-LD isAccessibleForFree, price 0'
}
};
let v = 'ticket', z = 'offen';
const menge = {};
let bestaetigt = null;
const T = () => TERMINE[v];
const startAkt = () => new Date(z === 'verschoben' ? T().neu : T().start);
const restAkt = () => z === 'knapp' ? T().rest.knapp : (z === 'offen' || z === 'verschoben') ? T().rest.offen : 0;
function seite() {
const t = T();
html.dataset.v = v; main.dataset.zustand = z;
ref('sparte').textContent = t.sparte; ref('sparte-link').textContent = t.sparte; ref('krume').textContent = t.titel;
ref('titel').textContent = t.titel; ref('untertitel').textContent = t.untertitel; ref('bildtext').textContent = t.bildtext; ref('h-abend').textContent = t.hAbend;
const alt = new Date(t.start), neu = new Date(t.neu);
const zeile = d => `${tagKurz.format(d).replace(',', '')} · ${uhr(d)} · ${ORT.raum}`;
ref('datumzeile').innerHTML = z === 'verschoben' ? `<del><span class="vh">ursprünglich </span>${tagKurz.format(alt).replace(',', '')}</del> <ins><span class="vh">neu: </span>${zeile(neu)}</ins>` : `<time datetime="${t.start}">${zeile(alt)}</time>${z === 'abgesagt' ? ' · <strong>abgesagt</strong>' : ''}`;
// Fakten
const d = startAkt(), e = new Date(z === 'verschoben' ? t.neuEinlass : t.einlass), en = new Date(z === 'verschoben' ? t.neuEnde : t.ende);
const preisText = t.frei ? 'Eintritt frei' : `${eur.format(t.preise[1].preis)} bis ${eur.format(t.preise[0].preis)}`;
$('[data-fakten]').innerHTML = [
['Tag', `<time datetime="${d.toISOString()}">${tagLang.format(d)}</time>`],
['Zeit', `Beginn ${uhr(d)}<small>Einlass ${uhr(e)}, Ende ${uhr(en).replace(' Uhr', '')} Uhr</small>`],
['Ort', `${ORT.raum}<small>${ORT.name}, ${ORT.stadt}</small>`],
['Preis', `${preisText}<small>${t.frei ? 'Anmeldung erbeten' : 'Endpreise, keine Gebühren'}</small>`],
['Dauer', t.dauer], ['Alter', t.alter], ['Sprache', t.sprache], ['Plätze', `${t.kapazitaet}<small>freie Platzwahl</small>`]
].map(([a, b]) => `<div><dt>${a}</dt><dd>${b}</dd></div>`).join('');
ref('text').innerHTML = t.text.map(p => `<p>${p}</p>`).join('');
ref('leute').innerHTML = t.leute.map(([n, r]) => `<li><b>${n}</b><span>${r}</span></li>`).join('');
ref('termine').innerHTML = t.termine.map(([dt, ti, sp]) => `<li><time class="mono" datetime="${dt}">${tagKurz.format(new Date(dt)).replace(',', '')} · ${uhr(new Date(dt))}</time><a href="#">${ti}</a><span class="chip">${sp}</span></li>`).join('');
hinweis(); box(); leiste(); jsonld();
}
function hinweis() {
const t = T(), h = $('[data-hinweis-text]');
const texte = {
offen: '',
knapp: `<span class="marke-z">Wenige Plätze</span>Nur noch ${restAkt()} von ${t.kapazitaet} Plätzen frei.`,
ausgebucht: `<span class="marke-z">Ausgebucht</span>Alle Plätze sind vergeben. <span>Die Warteliste ist offen, an der Abendkasse gibt es manchmal Restplätze.</span>`,
abgesagt: `<span class="marke-z">Abgesagt</span>${v === 'ticket' ? 'Das Konzert' : 'Das Gespräch'} am ${tagLang.format(new Date(t.start))} entfällt wegen Erkrankung im Ensemble. <span>${t.frei ? 'Alle Angemeldeten bekommen eine E-Mail.' : 'Reservierungen verfallen, bezahlt wurde noch nichts.'}</span>`,
verschoben: `<span class="marke-z">Verschoben</span>Neuer Termin: ${tagLang.format(new Date(t.neu))}, ${uhr(new Date(t.neu))}. <span>Ihre Reservierung gilt weiter.</span>`,
vergangen: `<span class="marke-z">Vorbei</span>Diese Veranstaltung liegt in der Vergangenheit. <span>Unten finden Sie die nächsten Termine.</span>`
};
h.innerHTML = texte[z];
}
function box() {
const t = T(), b = $('[data-box-inhalt]'), rest = restAkt();
const maxGesamt = t.frei ? 2 : Math.min(MAX, rest);
t.preise.forEach(p => { menge[p.id] = Math.min(menge[p.id] || 0, maxGesamt); });
ref('box-titel').textContent = t.frei ? 'Anmeldung' : 'Plätze';
ref('box-meta').textContent = z === 'abgesagt' ? 'ABGESAGT' : z === 'vergangen' ? 'VORBEI' : z === 'ausgebucht' ? 'AUSGEBUCHT' : `${rest} FREI`;
let inhalt = '';
if (bestaetigt && (z === 'offen' || z === 'knapp' || z === 'verschoben')) {
inhalt = `<div class="bestaetigt" tabindex="-1" data-bestaetigt><h3>${t.frei ? 'Angemeldet' : 'Reserviert'}</h3>
<dl><dt>Nummer</dt><dd class="mono">${bestaetigt.nr}</dd><dt>${t.frei ? 'Personen' : 'Plätze'}</dt><dd>${bestaetigt.anzahl}</dd>${t.frei ? '' : `<dt>Summe</dt><dd>${eur.format(bestaetigt.summe)}</dd>`}<dt>Termin</dt><dd>${tagLang.format(startAkt())}, ${uhr(startAkt())}</dd></dl>
<p class="klein" style="margin-top:10px">${t.frei ? 'Die Bestätigung kommt per E-Mail.' : 'Bitte holen Sie die Karten bis 19:45 Uhr an der Abendkasse ab. Bezahlt wird dort, bar oder mit Karte.'}</p></div>
<button type="button" class="kalender" data-ics>In den Kalender (.ics)</button>
<button type="button" class="klein" style="text-decoration:underline" data-neu-buchen>Weitere ${t.frei ? 'Anmeldung' : 'Reservierung'}</button>`;
} else if (z === 'offen' || z === 'knapp' || z === 'verschoben') {
const belegt = 1 - rest / t.kapazitaet;
inhalt = `<div class="rest"><p><span>${t.frei ? 'Freie Plätze' : 'Restplätze'}</span>Noch ${rest} von ${t.kapazitaet}</p><div class="balken" aria-hidden="true"><b style="--belegt:${belegt.toFixed(3)}"></b></div></div>
<form data-form novalidate>
<fieldset style="border:0"><legend class="vh">${t.frei ? 'Personen' : 'Preiskategorien'}</legend>
${t.preise.map(p => `<div class="kat"><b id="k-${p.id}">${p.name}</b>${t.frei ? '' : `<span class="preis vh">${eur.format(p.preis)}</span>`}<small>${t.frei ? p.info : `<span class="preis">${eur.format(p.preis)}</span>${p.info ? ' · ' + p.info : ''}`}</small>
<span class="stepper" role="group" aria-labelledby="k-${p.id}"><button type="button" data-minus="${p.id}" aria-label="${p.name}: einen weniger">−</button><output aria-live="polite" data-zahl="${p.id}">${menge[p.id] || 0}</output><button type="button" data-plus="${p.id}" aria-label="${p.name}: einen mehr">+</button></span></div>`).join('')}
</fieldset>
${t.frei ? '' : `<p class="summe" style="margin-top:14px"><span>Summe, zahlbar an der Abendkasse</span><output aria-live="polite" data-summe>0,00 €</output></p>`}
<div class="feld" style="margin-top:14px"><label for="f-name">Name</label><input id="f-name" name="name" autocomplete="name" required aria-describedby="e-name"><span class="fehler" id="e-name" hidden>Bitte Ihren Namen angeben.</span></div>
<div class="feld" style="margin-top:10px"><label for="f-mail">E-Mail</label><input id="f-mail" name="email" type="email" autocomplete="email" required aria-describedby="e-mail"><span class="fehler" id="e-mail" hidden>Bitte eine gültige E-Mail-Adresse angeben.</span></div>
${t.frei ? `<div class="feld" style="margin-top:10px"><label for="f-hilfe">Brauchen Sie Unterstützung? (freiwillig)</label><textarea id="f-hilfe" name="hilfe" placeholder="z. B. Rollstuhlplatz, Begleitperson"></textarea></div>` : ''}
<p class="fehler klein" data-fehler-menge hidden style="margin-top:10px;color:var(--warn);font-weight:700">Bitte mindestens ${t.frei ? 'eine Person' : 'einen Platz'} wählen.</p>
<button class="haupt" type="submit" style="margin-top:14px"><span>${t.frei ? 'Anmelden' : 'Verbindlich reservieren'}</span><span aria-hidden="true">→</span></button>
<p class="klein" style="margin-top:10px">${t.frei ? 'Wir speichern Name und E-Mail nur für diese Veranstaltung.' : 'Nicht abgeholte Plätze gehen um 19:45 Uhr in den freien Verkauf. Stornieren bis zum Vortag per Link in der E-Mail.'}</p>
</form>
<button type="button" class="kalender" data-ics>In den Kalender (.ics)</button>`;
} else if (z === 'ausgebucht') {
inhalt = `<div class="zustandsblock"><h3>Ausgebucht</h3><p class="klein" style="margin-top:8px">Alle ${t.kapazitaet} Plätze sind vergeben. Wir melden uns per E-Mail, wenn Plätze frei werden, in der Reihenfolge der Einträge.</p></div>
<form data-warteliste novalidate>
<div class="feld"><label for="w-mail">E-Mail für die Warteliste</label><input id="w-mail" type="email" autocomplete="email" required></div>
<button class="haupt" type="submit" style="margin-top:12px"><span>Auf die Warteliste</span><span aria-hidden="true">→</span></button>
</form>
<button type="button" class="kalender" data-ics>In den Kalender (.ics)</button>`;
} else if (z === 'abgesagt') {
inhalt = `<div class="zustandsblock"><h3>Abgesagt</h3><p style="margin-top:8px">${t.frei ? 'Ihre Anmeldung verfällt, Sie müssen nichts tun. Wir schreiben allen Angemeldeten per E-Mail.' : 'Bezahlt wird erst an der Abendkasse, deshalb gibt es nichts zu erstatten. Reservierungen verfallen von selbst.'}</p><p class="klein" style="margin-top:8px">Fragen: 0741 000 00-10 oder kasse@saegewerk.example</p></div>
<a class="haupt" href="#h-mehr" style="text-decoration:none"><span>Andere Termine ansehen</span><span aria-hidden="true">↓</span></a>`;
} else {
inhalt = `<div class="zustandsblock"><h3>Vorbei</h3><p style="margin-top:8px">${v === 'ticket' ? 'Das Konzert' : 'Das Gespräch'} fand am ${tagLang.format(new Date(t.start))} statt.</p></div>
<a class="haupt" href="#h-mehr" style="text-decoration:none"><span>Nächste Termine</span><span aria-hidden="true">↓</span></a>`;
}
b.innerHTML = `<div class="einblenden">${inhalt}</div>`;
zahlen();
}
function zahlen() {
const t = T(), rest = restAkt(), maxGesamt = t.frei ? 2 : Math.min(MAX, rest);
const ges = t.preise.reduce((s, p) => s + (menge[p.id] || 0), 0);
t.preise.forEach(p => {
const o = $(`[data-zahl="${p.id}"]`); if (!o) return;
o.textContent = menge[p.id] || 0;
$(`[data-minus="${p.id}"]`).disabled = !(menge[p.id] > 0);
$(`[data-plus="${p.id}"]`).disabled = ges >= maxGesamt;
});
const s = $('[data-summe]'); if (s) s.textContent = eur.format(t.preise.reduce((a, p) => a + p.preis * (menge[p.id] || 0), 0));
}
function leiste() {
const t = T(), txt = $('[data-leiste-text]'), k = $('[data-leiste-knopf]');
const ab = t.frei ? 'Eintritt frei' : 'ab ' + eur.format(Math.min(...t.preise.filter(p => p.preis > 0).map(p => p.preis)));
const s = { offen: [ab, `Noch ${restAkt()} Plätze`, t.frei ? 'Anmelden' : 'Plätze reservieren'], knapp: [ab, `Nur noch ${restAkt()} Plätze`, t.frei ? 'Anmelden' : 'Plätze reservieren'], ausgebucht: ['Ausgebucht', 'Warteliste offen', 'Zur Warteliste'], abgesagt: ['Abgesagt', 'entfällt ersatzlos', 'Details'], verschoben: ['Verschoben', tagKurz.format(new Date(t.neu)).replace(',', ''), t.frei ? 'Anmelden' : 'Plätze reservieren'], vergangen: ['Vorbei', 'nächste Termine unten', 'Termine'] }[z];
txt.innerHTML = `<b>${s[0]}</b>${s[1]}`; k.textContent = s[2];
}
function ics() {
const t = T(), d = startAkt(), en = new Date(z === 'verschoben' ? t.neuEnde : t.ende);
const lokal = x => { const p = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(x); return p.replace(/[-: ]/g, '').replace(/^(\d{8})(\d{6})$/, '$1T$2'); };
const utc = x => x.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const esc = s => s.replace(/([,;\\])/g, '\\$1');
const zeilen = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Kulturhaus Saegewerk//Programm//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT',
`UID:${t.slug}-${utc(new Date(t.start)).slice(0, 8)}@saegewerk.example`, `DTSTAMP:${utc(new Date())}`, `DTSTART;TZID=Europe/Berlin:${lokal(d)}`, `DTEND;TZID=Europe/Berlin:${lokal(en)}`,
`SUMMARY:${esc(t.titel)}`, `LOCATION:${esc(`${ORT.name}, ${ORT.raum}, ${ORT.strasse}, ${ORT.plz} ${ORT.stadt}`)}`, `URL:https://www.saegewerk.example/programm/${t.slug}`,
`DESCRIPTION:${esc(t.untertitel + ' Einlass ' + uhr(new Date(t.einlass)) + '.')}`, `STATUS:${z === 'abgesagt' ? 'CANCELLED' : 'CONFIRMED'}`, 'END:VEVENT', 'END:VCALENDAR'];
return zeilen.join('\r\n') + '\r\n';
}
function kalender(laden = true) {
const datei = T().slug + '.ics';
if (laden) { const url = URL.createObjectURL(new Blob([ics()], { type: 'text/calendar;charset=utf-8' })); const a = document.createElement('a'); a.href = url; a.download = datei; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); }
toast(`Kalenderdatei „${datei}“ erstellt. Öffnen Sie sie, um den Termin zu übernehmen.`);
}
let toastT;
function toast(t) { const el = $('[data-toast]'); el.textContent = t; el.setAttribute('data-an', ''); clearTimeout(toastT); toastT = setTimeout(() => el.removeAttribute('data-an'), 3200); }
function jsonld() {
const t = T(), status = { abgesagt: 'EventCancelled', verschoben: 'EventRescheduled' }[z] || 'EventScheduled';
const avail = z === 'ausgebucht' ? 'SoldOut' : 'InStock';
const o = { '@context': 'https://schema.org', '@type': t.frei ? 'Event' : 'MusicEvent', name: t.titel, description: t.untertitel,
startDate: z === 'verschoben' ? t.neu : t.start, endDate: z === 'verschoben' ? t.neuEnde : t.ende, doorTime: z === 'verschoben' ? t.neuEinlass : t.einlass,
...(z === 'verschoben' ? { previousStartDate: t.start } : {}),
eventStatus: 'https://schema.org/' + status, eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
location: { '@type': 'Place', name: `${ORT.name}, ${ORT.raum}`, address: { '@type': 'PostalAddress', streetAddress: ORT.strasse, postalCode: ORT.plz, addressLocality: ORT.stadt, addressCountry: 'DE' } },
image: ['https://www.saegewerk.example/bilder/' + t.slug + '-16x9.jpg'],
organizer: { '@type': 'Organization', name: 'Kulturhaus Sägewerk gGmbH', url: 'https://www.saegewerk.example/' },
...(t.frei ? { isAccessibleForFree: true } : { performer: { '@type': 'MusicGroup', name: 'Spulwerk' } }),
offers: t.preise.filter(p => !t.frei || true).map(p => ({ '@type': 'Offer', name: p.name, price: p.preis.toFixed(2), priceCurrency: 'EUR', ...(z === 'abgesagt' || z === 'vergangen' ? {} : { availability: 'https://schema.org/' + avail }), url: 'https://www.saegewerk.example/programm/' + t.slug, validFrom: '2026-10-01T10:00:00+02:00' }))
};
const s = JSON.stringify(o, null, 2); $('#ld').textContent = s; $('[data-ld-text]').textContent = s;
}
// Ereignisse in der Platzbox
$('[data-box]').addEventListener('click', e => {
const p = e.target.closest('[data-plus]'), m = e.target.closest('[data-minus]');
if (p) { menge[p.dataset.plus] = (menge[p.dataset.plus] || 0) + 1; zahlen(); $('[data-fehler-menge]') && ($('[data-fehler-menge]').hidden = true); }
if (m) { menge[m.dataset.minus] = Math.max(0, (menge[m.dataset.minus] || 0) - 1); zahlen(); }
if (e.target.closest('[data-ics]')) kalender(!laeuft);
if (e.target.closest('[data-neu-buchen]')) { bestaetigt = null; T().preise.forEach(p => menge[p.id] = 0); box(); }
});
$('[data-box]').addEventListener('submit', e => {
e.preventDefault();
const f = e.target;
if (f.matches('[data-warteliste]')) { const i = f.querySelector('input'); if (!i.value || !i.checkValidity()) { i.setAttribute('aria-invalid', 'true'); i.focus(); return; } f.innerHTML = '<p class="bestaetigt" tabindex="-1"><b>Eingetragen.</b> Wir schreiben an ' + i.value.replace(/</g, '') + ', sobald ein Platz frei wird.</p>'; f.firstChild.focus(); return; }
const t = T(), anzahl = t.preise.reduce((s, p) => s + (menge[p.id] || 0), 0);
let fehler = null;
const fm = $('[data-fehler-menge]'); fm.hidden = anzahl > 0; if (!anzahl) fehler = fehler || $('[data-plus]');
[['f-name', 'e-name'], ['f-mail', 'e-mail']].forEach(([fid, eid]) => { const i = $('#' + fid), ok = i.value.trim() && i.checkValidity(); i.setAttribute('aria-invalid', !ok); $('#' + eid).hidden = ok; if (!ok) fehler = fehler || i; });
if (fehler) { fehler.focus(); return; }
bestaetigt = { nr: (t.frei ? 'G-' : 'S-') + '2711-' + String(Math.floor(1000 + Math.random() * 8999)), anzahl, summe: t.preise.reduce((a, p) => a + p.preis * (menge[p.id] || 0), 0) };
box(); $('[data-bestaetigt]').focus();
});
$('[data-leiste-knopf]').addEventListener('click', e => { e.preventDefault(); const b = $('[data-box]'); b.scrollIntoView({ behavior: reduziert() ? 'auto' : 'smooth', block: 'start' }); setTimeout(() => (b.querySelector('button, input, a') || b).focus({ preventScroll: true }), reduziert() ? 0 : 500); });
const mk = $('.menueknopf'), nv = $('#hauptmenue');
mk.addEventListener('click', () => { const auf = !nv.hasAttribute('data-offen'); nv.toggleAttribute('data-offen', auf); mk.setAttribute('aria-expanded', auf); mk.textContent = auf ? 'Schließen' : 'Menü'; });
addEventListener('keydown', e => { if (e.key === 'Escape' && nv.hasAttribute('data-offen')) { mk.click(); mk.focus(); } });
// Pult
function setze(neuV = v, neuZ = z, url = true) {
if (neuV !== v) { bestaetigt = null; Object.keys(menge).forEach(k => delete menge[k]); }
v = TERMINE[neuV] ? neuV : 'ticket'; z = neuZ;
$$('.pult [data-v]').forEach(b => b.setAttribute('aria-pressed', b.dataset.v === v));
$$('.pult [data-z]').forEach(b => b.setAttribute('aria-pressed', b.dataset.z === z));
$('[data-werte]').textContent = T().werte;
seite();
if (url) { const q = new URLSearchParams(location.search); q.set('v', v); z !== 'offen' ? q.set('zustand', z) : q.delete('zustand'); history.replaceState(null, '', location.pathname + '?' + q.toString()); }
}
$$('.pult [data-v]').forEach(b => b.addEventListener('click', () => { pause(); setze(b.dataset.v, z); }));
$$('.pult [data-z]').forEach(b => b.addEventListener('click', () => { pause(); setze(v, b.dataset.z); }));
const dlg = $('dialog.ld');
$('[data-ld-knopf]').addEventListener('click', () => { jsonld(); dlg.showModal(); });
$('[data-ld-zu]').addEventListener('click', () => dlg.close());
// Vorführung
const zeiger = $('[data-zeiger]');
let laeuft = false, timer = [];
const warte = ms => new Promise(r => timer.push(setTimeout(r, ms)));
async function gehe(el, klick = true) {
if (!el || !laeuft) return;
const r = el.getBoundingClientRect();
zeiger.style.transition = reduziert() ? 'none' : 'transform 550ms cubic-bezier(0.25, 1, 0.5, 1), opacity 200ms';
zeiger.style.transform = `translate(${r.left + r.width / 2}px, ${r.top + r.height / 2}px)`;
await warte(reduziert() ? 120 : 600);
if (klick && laeuft) { zeiger.classList.add('drueckt'); await warte(110); zeiger.classList.remove('drueckt'); el.click(); }
}
async function runde() {
if (!laeuft) return;
bestaetigt = null; Object.keys(menge).forEach(k => delete menge[k]); setze(v, 'offen', false);
const kleinesFenster = innerWidth < 1101;
scrollTo(0, kleinesFenster ? $('[data-box]').getBoundingClientRect().top + scrollY - 20 : 0);
zeiger.style.transform = `translate(${innerWidth * .5}px, ${innerHeight * .8}px)`;
await warte(900);
const id0 = T().preise[0].id, id1 = (T().preise[1] || T().preise[0]).id;
await gehe($(`[data-plus="${id0}"]`)); await warte(350);
await gehe($(`[data-plus="${id0}"]`)); await warte(350);
await gehe($(`[data-plus="${id1}"]`)); await warte(900);
await gehe($('[data-ics]')); await warte(1600);
for (const zz of ['knapp', 'ausgebucht', 'abgesagt', 'verschoben']) {
if (!laeuft) return; setze(v, zz, false);
if (kleinesFenster) { scrollTo(0, 0); await warte(1100); scrollTo(0, $('[data-box]').getBoundingClientRect().top + scrollY - 20); await warte(1000); }
else await warte(1900);
}
if (laeuft) timer.push(setTimeout(runde, 300));
}
function neustart() { pause(); bestaetigt = null; setze(v, 'offen', false); scrollTo(0, 0); }
function vorfuehren() { pause(); html.classList.add('vorfuehrung'); laeuft = true; runde(); }
function pause() { laeuft = false; timer.forEach(clearTimeout); timer = []; html.classList.remove('vorfuehrung'); }
function weiter() { if (!laeuft) vorfuehren(); }
const q = new URLSearchParams(location.search);
const zs = ['offen', 'knapp', 'ausgebucht', 'abgesagt', 'verschoben', 'vergangen'];
setze(q.get('v') || 'ticket', zs.includes(q.get('zustand')) ? q.get('zustand') : 'offen', false);
})();
</script>
</body>
</html>