diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..fed18f9b --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,360 @@ +--- +name: Posaunenwerk Rheinland +description: Das Programmheft – weißes Programmpapier, orange Druckflächen mit tiefschwarzer Schrift, Archivo in zwei Breiten, Tabellensatz mit Haarlinien. +colors: + primary: "#faa619" + primary-ink: "#a55800" + foerderverein: "#78a8da" + ink: "#1c1d1f" + paper: "#ffffff" + slate: "#58595b" + rule: "#d4d4d1" + night: "#141517" + night-raised: "#1d1f22" + night-rule: "#34373c" + night-text: "#ecebe8" + night-muted: "#a6a8ad" + cancelled: "#b91c1c" + cancelled-night: "#f87171" + district-1: "#3b82f6" + district-2: "#10b981" + district-3: "#8b5cf6" + district-4: "#f59e0b" + district-5: "#ef4444" + district-6: "#06b6d4" + district-7: "#ec4899" + district-8: "#14b8a6" + district-9: "#6366f1" + district-10: "#84cc16" + district-11: "#f97316" + district-12: "#a855f7" + district-13: "#22d3ee" +typography: + display: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "clamp(2.75rem, 6vw, 5.75rem)" + fontWeight: 800 + lineHeight: 0.9 + letterSpacing: "-0.01em" + fontVariation: "'wdth' 68" + headline: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "clamp(2.25rem, 4.5vw, 3.5rem)" + fontWeight: 800 + lineHeight: 0.95 + fontVariation: "'wdth' 68" + programm-head: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "2.25rem" + fontWeight: 800 + lineHeight: 1 + fontVariation: "'wdth' 68" + list-head: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "1.75rem" + fontWeight: 800 + lineHeight: 1 + fontVariation: "'wdth' 68" + title: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "1.5rem" + fontWeight: 700 + lineHeight: 1.1 + fontVariation: "'wdth' 68" + date-numeral: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "2.75rem" + fontWeight: 800 + lineHeight: 1 + fontFeature: "'tnum' 1" + fontVariation: "'wdth' 68" + lead: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "clamp(1.25rem, 2.1vw, 1.875rem)" + fontWeight: 500 + lineHeight: 1.375 + fontVariation: "'wdth' 84" + body: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "0.9375rem" + fontWeight: 400 + lineHeight: 1.5 + meta: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "0.875rem" + fontWeight: 600 + lineHeight: 1.25 + fontVariation: "'wdth' 84" + button: + fontFamily: "Archivo, ui-sans-serif, system-ui, sans-serif" + fontSize: "1rem" + fontWeight: 600 + lineHeight: 1 + fontVariation: "'wdth' 84" +rounded: + none: "0px" + avatar: "9999px" +spacing: + sheet-mobile: "1.25rem" + sheet-tablet: "2.5rem" + sheet-desktop: "3.5rem" + sheet-max: "100rem" + row-y: "1rem" + section-y: "4rem" + section-y-md: "6rem" +components: + button-primary: + backgroundColor: "{colors.ink}" + textColor: "{colors.paper}" + typography: "{typography.button}" + rounded: "{rounded.none}" + padding: "0 24px" + height: "48px" + button-primary-hover: + backgroundColor: "{colors.paper}" + textColor: "{colors.ink}" + button-register: + backgroundColor: "transparent" + textColor: "{colors.ink}" + typography: "{typography.button}" + rounded: "{rounded.none}" + padding: "0 16px" + height: "40px" + button-register-hover: + backgroundColor: "{colors.paper}" + button-icon: + backgroundColor: "transparent" + textColor: "{colors.ink}" + rounded: "{rounded.none}" + size: "44px" + button-icon-hover: + backgroundColor: "{colors.ink}" + textColor: "{colors.paper}" + date-tile-open: + backgroundColor: "{colors.primary}" + textColor: "{colors.ink}" + typography: "{typography.date-numeral}" + rounded: "{rounded.none}" + padding: "8px 8px 10px" + width: "80px" + way-row: + backgroundColor: "{colors.paper}" + textColor: "{colors.ink}" + typography: "{typography.title}" + rounded: "{rounded.none}" + padding: "0 4px" + height: "56px" + way-row-hover: + backgroundColor: "{colors.primary}" + textColor: "{colors.ink}" + nav-link: + textColor: "{colors.slate}" + typography: "{typography.button}" + height: "44px" + nav-link-active: + textColor: "{colors.ink}" + menu-panel: + backgroundColor: "{colors.paper}" + textColor: "{colors.ink}" + rounded: "{rounded.none}" + padding: "4px 0" + menu-panel-item-hover: + backgroundColor: "{colors.primary}" + textColor: "{colors.ink}" + banner-info: + backgroundColor: "{colors.primary}" + textColor: "{colors.ink}" + padding: "8px 16px" +--- + +# Design System: Posaunenwerk Rheinland + +## Overview + +**Creative North Star: "Das Programmheft"** + +Die öffentliche Website ist gesetzt wie das Programmheft eines Bläsertags: weißes Programmpapier, volle orange Druckflächen mit tiefschwarzer Schrift, Termine im Tabellensatz mit festen Datumsspalten und 1px-Haarlinien. Die Schrift ist eine Familie in zwei Breiten: Archivo schmal und extrafett für Titel, Daten und Köpfe (ein Echo der Logo-Wortmarke), normal breit für Lesetext. Die Wirkung soll lebendig, verlässlich und unverwechselbar „unser Verband“ sein, nicht kirchlich-altbacken, agentur-schick, verspielt-kindlich oder behördlich-nüchtern. + +Die Tiefe entsteht aus Druckmitteln, nicht aus Licht: Linien in zwei Stärken (2px Tinte als Kopf- und Abschnittsstrich, 1px Haarlinie zwischen Zeilen), Farbflächen und Anschnitt. Inhalte stehen in Zeilen und Spalten statt in Karten; Wege sind volle Zeilen, die sich beim Zeigen von links orange füllen. Der Dunkelmodus („Nachtdruck“) setzt dasselbe Heft auf einen fast schwarzen Grund; Orange bleibt Druckfarbe. + +Das Programmheft gilt für die Startseite, die geteilte Chrome (Navigation, Beta-Banner, Fußzeile) und den Kopf aller Innenseiten (`PublicPage`); /mitmachen ist die Referenz-Innenseite. Die Bausteine liegen in `src/app/_components/programmheft/`. Die übrigen Abschnitte der Innenseiten sowie EventCard, CourseCard und PostCard tragen noch die alte Welt, stehen übergangsweise schon in Archivo und werden seitengruppenweise übertragen; sie sind nicht Teil dieses Systems. `ui/card`, `ui/badge` und `ui/alert` gehören dem Dashboard, die öffentliche Seite nutzt Kasten (Panel), Etikett (Tag) und Hinweis (Note). Das Dashboard bleibt vorerst bei Inter und seinen eigenen Tokens. + +**Key Characteristics:** +- Papier, Tinte und eine Druckfarbe (Orange); Blau nur für den Förderverein. +- Archivo über die Breitenachse: schmal-fett (`wdth` 68) spricht, halbschmal (`wdth` 84) etikettiert, normal liest. +- Tabellensatz: feste Datumsslots mit Tabellenziffern, Haarlinien zwischen Zeilen, 2px-Striche unter Köpfen. +- Keine Karten, Schatten, Rundungen, Icon-Kreise oder Farbstreifen. +- Wege als volle Zeilen mit Pfeil, die sich bei Hover/Fokus von links orange füllen. + +## Colors + +Eine Druckfarbe auf Papier und Tinte, in zwei Druckgängen (Tag und Nacht). + +### Primary +- **Druckorange** (#faa619): Die verbindliche Markenfarbe als volle Fläche: Titelblatt, Schlussaufruf („Lust auf Posaunenchor?“, „Noch Fragen?“), Datumsfeld bei offener Anmeldung, Frist-Hervorhebung ≤ 7 Tage, Hinweis „wichtig“ (z. B. Kündigungsfrist), Zeilenfüllung bei Hover/Fokus, Hover in Menüs, Beta-Banner, Aktiv-Unterstrich der Navigation, Textmarkierung. Auf Orange steht immer Tinte. Im Nachtdruck ist Druckorange auch Textfarbe (Links, Status „Anmeldung offen“, Fußzeilenköpfe) sowie Fokusring. +- **Messing-Tinte** (#a55800): Orange als Text auf hellem Grund – Links wie „Alle News“, Status „Anmeldung offen“, Caret. Nie als Fläche. + +### Secondary +- **Fördervereinsblau** (#78a8da): Markenfarbe des Fördervereins, ausschließlich für Förderverein-Inhalte reserviert: als volle Druckfläche eines Förderverein-Abschnitts (Tinte darauf, ≈ 6,8:1, auch im Nachtdruck unverändert blau), als Satzstrich im Seitenkopf der Förderverein-Seite und als Zeilenfüllung von Wegzeilen, die zum Förderverein führen. Nie als Textfarbe auf Papier (2,5:1). Auf der Startseite nicht im Einsatz. + +### Neutral +- **Tinte** (#1c1d1f): Haupttext, Köpfe, 2px-Striche, primäre Schaltflächen, Fokusring auf Papier und Orange, Grund der Fußzeile und leeres Bildfeld. +- **Papier** (#ffffff): Seitengrund und Text auf Tinte. +- **Schiefer** (#58595b): Sekundärtext, Metadaten, inaktive Navigationslinks. +- **Haarlinie** (#d4d4d1): 1px-Trennlinien zwischen Programm- und Registerzeilen, Lade-Platzhalter. +- **Nachtgrund** (#141517), **Nacht erhöht** (#1d1f22), **Nachtlinie** (#34373c), **Nachtschrift** (#ecebe8), **Nacht gedämpft** (#a6a8ad): die Entsprechungen im Dunkelmodus für Grund, Menüs/Fußzeile/Bildfeld, Linien, Text und Sekundärtext. +- **Abgesagt** (#b91c1c, Nacht #f87171): nur der Status „Abgesagt“ zusammen mit durchgestrichenem Titel. + +### Bezirksfarben +- **Bezirk 01–13** (`district-1` … `district-13`): markieren ausschließlich einen Bezirk, als 10px-Quadrat vor dem Text „Bezirk NN · Name“. Termine ohne Bezirk tragen den Text „Bezirksübergreifend“ ohne Marke. + +### Named Rules +**The Ink-on-Orange Rule.** Auf Orange steht immer Tinte (#1c1d1f), nie Weiß. Orange als Text auf hellem Grund ist immer Messing-Tinte; nur auf dem Nachtgrund darf Druckorange selbst Text sein. + +**The Quiet Open Rule.** Offene Anmeldung wird so leise markiert, dass die Seite auch dann nicht orange wird, wenn jeder Kurs offen ist: oranges Datumsfeld + Statuszeile „Anmeldung offen“ + Outline-Schaltfläche „Anmelden“; eine orange Hinterlegung nur für den Countdown ab 7 Tagen vor Fristende. Keine vollflächig orangen Zeilen. + +**The District Marker Rule.** Bezirksfarben sind Kennzeichen, keine Gestaltung: kleines Quadrat plus Bezirkstext, nie als Abschnittsfarbe, Fläche, Rahmen oder Schmuck. + +**The Förderverein Blue Rule.** Blau erscheint nur, wo der Förderverein spricht. + +**The One Field Rule.** Neben Titelblatt und Schlussaufruf trägt eine Seite höchstens eine weitere volle Farbfläche: die blaue Förderverein-Fläche, und nur für einen Abschnitt, in dem der Förderverein spricht. Eingeführt auf Wunsch des Eigentümers (2026-09-16), weil /mitmachen ohne Farbfläche „almost too minimalistic“ wirkte; Fotos lehnte er für diese Seite ab. + +## Typography + +**Display Font:** Archivo, variabel mit Breitenachse (mit ui-sans-serif, system-ui) +**Body Font:** Archivo in normaler Breite +**Label Font:** Archivo halbschmal (`wdth` 84) + +**Character:** Eine Familie, zwei Stimmen: die schmal-fette Stimme ruft wie ein Plakatkopf und echot die Logo-Wortmarke, die normale Breite liest sich ruhig und amtlich-freundlich. Datumsziffern sind tabellarisch, damit die Spalte steht. + +### Hierarchy +- **Display** (800, clamp(2.75rem, 6vw, 5.75rem), 0.9, `wdth` 68, −0.01em): Titel auf dem Titelblatt. +- **Headline** (800, clamp(2.25rem, 4.5vw, 3.5rem), 0.95, `wdth` 68): Abschnittsköpfe („Aktuelles“, Register); der Schlussaufruf nutzt dieselbe Stimme größer (clamp(2.5rem, 6vw, 5.25rem), 0.9). +- **Programmkopf** (800, 2.25rem, 1, `wdth` 68): Kopf einer Programmspalte, mit 2px-Tintenstrich darunter. +- **Listenkopf** (800, 1.75rem, 1, `wdth` 68): Kopf einer Registerliste; Fußzeilenköpfe gleiche Stimme in 1.5rem. +- **Title** (700, 1.5rem, 1.1, `wdth` 68): Zeilentitel in Programm, Register und Wegzeilen (1.375–1.5rem); Beitragstitel 1.75rem / 1.05. +- **Datumsziffer** (800, 2.75rem, 1, `wdth` 68, Tabellenziffern): Tag im Datumsslot; darunter der Monat halbschmal 600, 0.875rem, Versalien mit 0.06em. +- **Lead** (500, clamp(1.25rem, 2.1vw, 1.875rem), snug, `wdth` 84, max. 26ch): Leitsatz unter dem Titel. +- **Body** (400, 0.9375–1.125rem, 1.5–1.625, normale Breite): Zeit/Ort-Zeilen, Beschreibungen, Auszüge (max. 60ch). +- **Meta** (600, 0.875rem, `wdth` 84): Art, Bezirk, Datum, Status – steht unter dem Titel. +- **Button** (600, 1–1.125rem, `wdth` 84): Schaltflächen und Navigationslinks (1.0625rem). + +### Named Rules +**The Two Widths Rule.** Titel, Daten und Köpfe sind schmal-fett; Etiketten und Schaltflächen halbschmal; Lesetext normal breit. Keine zweite Schriftfamilie auf den Programmheft-Flächen. + +**The Metadata Below Rule.** Keine Kicker- oder Eyebrow-Zeilen über Überschriften. Art, Bezirk, Datum und Status stehen unter dem Titel. + +**The Condensed Head Rule.** Abschnitts- und Listenköpfe sprechen in der schmal-extrafetten Stimme, nicht in gesperrten Versalien. Versalien mit Sperrung bleiben dem Monatskürzel im Datumsslot vorbehalten. + +## Layout + +Das Blatt (`sheet`) ist maximal 100rem breit, zentriert, mit Innenrand 1.25rem (mobil), 2.5rem (ab 40rem) und 3.5rem (ab 64rem). Größere Kompositionen folgen einem 12-Spalten-Raster ab 64rem: der erste Bildschirm teilt sich 7/12 Titelblatt und 5/12 Programmspalte und füllt mindestens min(56rem, Viewport minus Kopf); Register 8/4, Schlussaufruf 6/12 Kopf und 5/12 Text ab Spalte 8. Mobil stapelt sich alles einspaltig; das Titelblatt löst sich in Titel, dann Programm, dann Foto auf, damit offene Anmeldungen direkt unter dem Titel stehen. + +Innenseiten: Der Seitenkopf teilt sich 7/12 Titel und 5/12 Leitsatz (unten bündig); ein längerer Vorspann steht stattdessen in voller Zeilenlänge (max. 65ch) unter dem Titel. Abschnitte teilen sich ab 64rem 4/12 Kopf und Einleitung und 8/12 Inhalt (`Split`); aufeinanderfolgende Abschnitte wechseln die Seite des Kopfes wie linke und rechte Heftseiten (links, rechts, links …). Im DOM steht der Kopf immer zuerst, mobil über dem Inhalt; zu lange Wörter im 4/12-Kopf brechen an einem gesetzten weichen Trennstrich („Einstiegs-/möglichkeiten“), nie per automatischer Silbentrennung. Fließtext läuft höchstens 65ch; Tabellen, Hinweise, Merkpunkte und Weglisten füllen die ganze Inhaltsspalte, damit die rechte Kante steht. Eine Wegliste direkt unter dem Seitenkopf beginnt ohne eigene Luft und ohne zweiten Strich (`PageSection flush="top"`). + +Rhythmus: Abschnitte 4rem vertikal, ab 48rem 6rem. Zeilen 1rem vertikal mit 1–1.5rem Spaltenabstand; Wegzeilen mindestens 3.5rem hoch. Mehrspaltige Beitragslisten trennen Spalten mit 1px-Haarlinien und 2.5rem Innenabstand statt mit Lücken zwischen Kästen. Die Programmspalte zeigt im ersten Bildschirm höchstens drei Zeilen; ein fehlender Platz wird als gestalteter leerer Programmplatz (kurzer Strich + „Weitere Termine folgen.“) gesetzt. Klickziele sind mindestens 44px hoch. + +Bewegung: Zeilenfüllung 280ms `cubic-bezier(0.16, 1, 0.3, 1)` von links; Titelblatt-Foto überblendet in 800ms, wechselt alle 7s nur ohne Hover/Fokus, ist anhaltbar und steht bei `prefers-reduced-motion` still. + +## Elevation & Depth + +Das System ist vollständig flach. Es gibt keine Schlagschatten. Trennung entsteht durch Linien und Flächen: 2px Tinte unter Köpfen, über Weglisten, zwischen Abschnitten und unter der fixierten Navigation; Abschnittsstriche und der Abschluss des Seitenkopfs laufen vollbreit (Nacht: Nachtlinie), Kopf- und Gruppenstriche stehen im Satzspiegel (Nacht: Nachtschrift); 1px Haarlinien zwischen Zeilen; Farbflächen (Orange, Tinte) für Gewicht. Aufklappende Menüs liegen ohne Schatten auf dem Inhalt und grenzen sich mit einem 2px-Tintenrahmen ab (Nacht: 2px Nachtlinie auf Nacht erhöht). Die Navigationsunterkante ist technisch ein `inset`-Schatten von 2px ohne Unschärfe; er wirkt und zählt als Linie. + +### Named Rules +**The Printed Depth Rule.** Tiefe kommt aus Linie, Fläche und Anschnitt, nie aus Licht. Braucht etwas Abstand zum Inhalt darunter, bekommt es einen 2px-Tintenrahmen, keinen Schatten. + +## Shapes + +Alles ist rechtwinklig (0px): Schaltflächen, Datumsfelder, Menüs, Bildfelder, Bezirksquadrate, Fokusringe. Einzige Ausnahme sind runde Avatar-Fotos bzw. Initialen im Benutzermenü. Linien kommen in zwei Stärken (2px Tinte, 1px Haarlinie); ein 6px × 96px Satzstrich trennt Titel und Leitsatz auf dem Titelblatt (Tinte auf Orange) und steht unter dem Vereinsnamen in der Fußzeile (Orange auf Tinte). Fotos laufen angeschnitten bis an die Kanten ihrer Fläche; Beitragsbilder stehen im Format 3:2, Titelblattfotos mobil 4:3. Fokus ist ein 3px-Umriss mit 2px Abstand (Tinte auf Papier und Orange, Druckorange auf Tinte und Nacht); ganze Zeilen zeigen den Fokus als 3px-Umriss nach innen. + +## Components + +### Buttons +Kräftig und gedruckt, rechteckige Farbfelder ohne Rundung. +- **Shape:** rechtwinklig (0px). +- **Primary:** Tinte mit Papierschrift, halbschmal 600, 48px hoch, 24px seitlich, Pfeil rechts. Hover kehrt um auf Papier mit Tinte. Login in der Navigation: 44px hoch, Hover Orange mit Tinte; im Nachtdruck Orange mit Tinte, Hover Papier. +- **Register (Outline):** 2px-Tintenrahmen, transparent, Tintenschrift, 40px hoch, 16px seitlich, Pfeil (extern: Pfeil schräg + Hinweis für Screenreader). Hover füllt Papier (sichtbar innerhalb der orange gefüllten Zeile). Nacht: Rahmen und Schrift Nachtschrift. +- **Icon-Schaltflächen:** 44px Quadrat, transparent; Hover invertiert auf Tinte mit Papier (Nacht: Nachtschrift mit Nachtgrund). Auf dem Titelblatt dieselbe Umkehr für die Bildstrecken-Steuerung. +- **Textlinks mit Pfeil:** halbschmal 600, Messing-Tinte (Nacht Orange), Unterstreichung mit 4px Abstand bei Hover. +- **Primary auf Papier** (`ButtonLink`): Tinte mit Papierschrift, mindestens 48px hoch; Hover Orange mit Tinte. Im Nachtdruck Orange mit Tinte, Hover Papier. Auf Orange (`surface="orange"`) kehrt der Hover wie auf dem Titelblatt auf Papier um. +- **Outline** (`ButtonLink variant="outline"`): 2px-Rahmen in Schriftfarbe, Hover füllt mit Schriftfarbe; auf Orange immer Tinte. Umrandet sind nur Aktionen, nie Etiketten. +- **Textlink im Fließtext** (`.link-ink`): Messing-Tinte 600 (Nacht Orange), immer 1px unterstrichen mit 4px Abstand, Hover 2px; auf Orange und auf Druckflächen Tinte. Pfeil-Icons folgen dem Ziel: Pfeil intern, schräger Pfeil extern, Brief für E-Mail, Download für Dateien (mit Screenreader-Hinweis zu Dateityp und neuem Tab). + +### Programmzeile (Signature) +Termine als Tabellensatz. Links der Datumsslot (64px, ab 40rem 80px): Tag als schmal-extrafette Tabellenziffer, darunter Monat (Jahr nur, wenn nicht das laufende). Rechts Titel (Title), darunter Meta-Zeile (Art · Bezirksmarke), Zeit · Ort in Body, dann Status. Ganz rechts ein Pfeil; die ganze Zeile ist per Link klickbar. 1px-Haarlinie unten. +- **Offene Anmeldung:** Datumsslot als orange Fläche (8px Innenrand), Statuszeile „Anmeldung offen“ in Messing-Tinte, Frist als Text; ab 7 Tagen vor Ende orange hinterlegt mit Tinte; Plätze als Text; Outline-Schaltfläche „Anmelden“. +- **Geschlossen / noch nicht offen:** Status in Schiefer, Datumsslot ohne Fläche. +- **Abgesagt:** Titel durchgestrichen in Schiefer, Status „Abgesagt“ in Rot. +- **Hover/Fokus:** Zeilenfüllung (siehe Wegzeile), alle Texte werden Tinte. + +### Wegzeile +Wege als volle Zeile auf Papier: Title-Stimme, mindestens 56px hoch, Pfeil rechts, 1px-Haarlinie unten; Gruppen beginnen mit einem 2px-Tintenstrich. Bei Hover (nur Zeigegeräte) und Fokus füllt sich die Zeile in 280ms von links mit Druckorange, alles darauf wird Tinte. Registerzeilen sind Wegzeilen mit Beschreibung in Body unter dem Titel, zweispaltig ab 48rem. + +### Meldung (Beitragsspalte) +Beiträge als Druckspalten, nicht als Karten: Bildfeld 3:2 auf Tinte (Nacht: Nacht erhöht), darunter Titel, Meta-Zeile (Datum, Kategorie, Bezirksmarke, „Angepinnt“) und Auszug (max. 4 Zeilen, 60ch). Spalten ab 64rem mit 1px-Haarlinie getrennt. Hover unterstreicht den Titel mit 3px Druckorange. Ohne Titelbild zeigt das Bildfeld das helle Logo auf Tinte, damit die Spalten gleich aufgebaut bleiben. + +### Titelblatt +Volle orange Fläche mit Tintenschrift: Display-Titel, Satzstrich, Lead, Primary-Schaltfläche. Darüber dem Foto eine Zeile mit Bildnachweis und Bildstrecken-Steuerung (Zurück, „n / m“ in Tabellenziffern, Weiter, Anhalten); das Foto läuft bis an die Kanten. + +### Schlussaufruf +Volle orange Fläche als bewusster Blickfang am Seitenende – vom Eigentümer ausdrücklich so gewünscht; nicht auf Papier verlegen. Headline-Stimme groß (clamp(2.5rem, 6vw, 5.25rem), 0.9) links (6/12), rechts ab Spalte 8 Text in Body (max. 40ch) und die Primary-Schaltfläche, z. B. „Mehr erfahren“; weitere Aktionen stehen als Outline daneben. Tinte auf Orange, im Nachtdruck unverändert Orange. Wiederverwendbar als `ClosingCall`, höchstens einmal pro Seite und immer als letzter Abschnitt vor der Fußzeile (Startseite: „Lust auf Posaunenchor?“, Mitmachen: „Noch Fragen?“). + +### Seitenkopf (Innenseite) +`PublicPage` setzt jede Innenseite mit `PageHead` auf Papier: Brotkrumen halbschmal 600 in Schiefer (aktuelle Seite Tinte, Klickziele 44px, „/“ als Trenner), darunter genau ein `

` in Display-Größe (ab 44 Zeichen und bei kompakten Köpfen in Headline-Größe), ein 6px × 96px Satzstrich in Tinte (Nacht: Nachtschrift; Förderverein: Fördervereinsblau) und rechts der Leitsatz in der Lead-Stimme (clamp(1.25rem, 1.9vw, 1.625rem), max. 34ch). Abschluss ist ein vollbreiter 2px-Strich. Keine Kicker darüber, keine farbigen Kopfbänder, keine zweite klebende Titelleiste. +- **Kompakt** (Kurs, Termin, Anmeldung, Ensemble): weniger Luft, Titel in Headline-Größe; ein abweichender Kurzname („Anmeldung“) steht halbschmal unter dem `

`. Rechts oben bündig die Meta-Spalte: Art, Dauer, Ort und Bezirksmarke als halbschmaler Schiefer-Text, Zustände als Etiketten, Aktionen („Bearbeiten“, „Teilen“) als 40px-Outline, darunter Zeilen mit Haarlinie für Datum, Ort, Plätze und Fristen mit Schiefer-Icons. + +### Abschnitt und Druckfläche +`PageSection` ist ein Abschnitt auf Papier im Satzspiegel, 4rem/6rem vertikal, optional mit vollbreitem 2px-Strich darüber; `Split` setzt darin Kopf (4/12) und Inhalt (8/12) und wechselt mit `side="right"` die Seite des Kopfes. Mit `surface="foerderverein"` wird er zur vollen Druckfläche in Fördervereinsblau (`.print-field`): alle Schrift, Kopf- und Gruppenstriche in Tinte, Haarlinien Tinte 30 %, Wegzeilen füllen sich papierweiß, Fokus in Tinte, im Nachtdruck unverändert. Ein Satzstrich auf der Fläche steht in Tinte. + +### Merkpunkte +`PointList` ersetzt Icon-Kacheln: Titel in der Title-Stimme, Text in Body-Schiefer darunter, 2px-Tintenstrich über der Liste, 1px-Haarlinien zwischen Zeilen und Spalten. Zwei Spalten ab 48rem für Gründe und Aufgaben, drei ab 40rem für kurze Fakten (z. B. „Nur 36 € / Jahr“ – ohne Großzahl-Inszenierung). + +### Beträge (Tabellensatz) +`ValueTable` für Beiträge und Kennwerte: Bezeichnung links in Body 1.125rem, Wert rechtsbündig schmal-extrafett 1.75rem mit Tabellenziffern, Haarlinien zwischen den Zeilen, 2px-Tintenstrich darüber. Beträge nie in Orange. + +### Personenzeile +`PersonRow` in `PersonList` (1–4 Spalten mit 2.5rem Abstand): rundes 56px-Foto (einzige Rundung neben dem Avatar), Name in der Title-Stimme (1.375rem), Amt in Body-Schiefer, „E-Mail senden“ als halbschmaler Textlink mit Brief-Icon (44px Klickziel, Name für Screenreader), Bildnachweis 0.875rem darunter; 1px-Haarlinie unten. + +### Etikett +`Tag` nur für Zustände: gefüllt, rechteckig, halbschmal 600 0.875rem, keine Versalien, keine Sperrung. Töne: Tinte (auch auf Orange, z. B. „Beta“), invers (Tinte; Nacht Nachtschrift – „Vergangen“, „Ausgebucht“, „Demnächst“), Orange mit Tinte („Nur Warteliste“, „Mitspielen möglich!“) und Rot nur für „Abgesagt“. + +### Hinweis +`Note` statt Alert-Box mit Seitenstreifen: `important` als orange Druckfläche mit Tinte für Fristen („Hinweis: … bis zum 30. November …“), `error` mit 2px-Rahmen und Kopf in Rot, `info` mit 2px-Tintenrahmen. Ein gewöhnlicher Zusatz (z. B. „Ehrungen“) ist kein Hinweis, sondern eine Gruppe mit Listenkopf. + +### Kasten +`Panel` für einen Block, der sich vom Satz abheben muss (z. B. „Im Schadensfall“ mit Unterlagenliste und Schaltfläche): 2px-Tintenrahmen auf Papier, im Nachtdruck 2px Nachtschrift auf Nachtgrund, 1.5–2rem Innenrand. Höchstens ein Kasten pro Seite. +### Navigation +Fixierte Leiste auf Papier (Nacht: Nachtgrund), 64px / ab 64rem 80px hoch, 2px-Tintenlinie unten. Links halbschmal 600, 1.0625rem, Schiefer; Hover Tinte mit 3px-Tintenunterstrich; aktiv Tinte mit 3px-Orange-Unterstrich. Aufklappmenüs und Benutzermenü: Papier mit 2px-Tintenrahmen, Einträge 12px/16px, Hover Orange mit Tinte, aktueller Eintrag mit 8px-Quadrat in Tinte (Nacht Orange). Mobil: Vollbild-Liste unter der Leiste, Zeilen mit Haarlinien, 1.25rem halbschmal, Untermenüs über eine 64px breite Aufklappzelle mit Haarlinie links, Hover Orange. + +### Fußzeile +Tintengrund (Nacht: Nacht erhöht) mit Papierschrift: Vereinsname in schmal-extrafetter Stimme, orangener Satzstrich, vier Spalten mit orangen Listenköpfen über einer hellen 1px-Linie (Papier 20 %), Links in Papier 85 %, Hover Orange mit Unterstreichung. Social-Icons als 44px-Quadrate, Hover Orange mit Tinte. Rechtszeile unter einer 1px-Linie (Papier 15 %) in 0.875rem. + +### Banner +Volle Druckfläche über der Navigation, Info-Variante Orange mit Tinte, 0.875rem Text mit unterstrichenem Link, 44px Schließen-Schaltfläche, Hover 15 % Schriftfarbe. Das Etikett („Beta“) ist ein Tinten-Tag ohne Sperrung und Versalien. Warnung und Wartung stehen als Tintenfläche mit Papierschrift (Nacht: Nachtschrift-Fläche mit Nachtgrund-Schrift) und orangem Etikett; eine grüne Erfolgs-Variante gibt es nicht. + +## Do's and Don'ts + +### Do: +- **Do** setze auf Orange immer Tinte (#1c1d1f) und nutze Messing-Tinte (#a55800) für orangen Text auf Papier; im Nachtdruck ist Druckorange Textfarbe. +- **Do** markiere offene Anmeldung mit orangem Datumsfeld, Statuszeile „Anmeldung offen“ und Outline-Schaltfläche „Anmelden“; orange Hinterlegung nur für den Countdown ab 7 Tagen. +- **Do** kennzeichne Bezirke mit 10px-Quadrat plus „Bezirk NN · Name“ und schreibe „Bezirksübergreifend“ als Text. +- **Do** trenne mit Linien: 2px Tinte unter Köpfen und über Weglisten, 1px Haarlinie zwischen Zeilen. +- **Do** setze Wege als volle Wegzeilen auf Papier mit Pfeil und Orange-Füllung bei Hover/Fokus; der Schlussaufruf „Lust auf Posaunenchor?“ steht bewusst auf voller oranger Fläche (Blickfang, vom Eigentümer gewünscht). +- **Do** zeige Beiträge ohne Titelbild mit dem hellen Logo auf dem Tinten-Bildfeld. +- **Do** halte WCAG 2.1 AA in Hell- und Dunkelmodus ein: Kontrast ≥ 4,5:1, 44px Klickziele, sichtbarer 3px-Fokus, anhaltbare Bildstrecke und `prefers-reduced-motion`. +- **Do** reserviere Fördervereinsblau (#78a8da) für Förderverein-Inhalte; wo der Förderverein einen ganzen Abschnitt spricht, darf er eine volle blaue Druckfläche mit Tinte bekommen. +- **Do** setze jede Innenseite mit `PublicPage`: Papierkopf, genau ein `

`, vollbreiter Abschlussstrich. +- **Do** setze Art, Dauer, Ort und Bezirk als Meta-Text; Etiketten nur für Zustände, Rahmen nur für Aktionen. +- **Do** beende eine Seite höchstens mit einem Schlussaufruf, und dann als letzten Abschnitt. + +### Don't: +- **Don't** Karten, Schlagschatten oder Rundungen verwenden (Ausnahme: runde Avatar-Fotos); schwebende Menüs trennen sich mit 2px-Tintenrahmen. +- **Don't** Icon-Kreise oder farbige Seiten- oder Kopfstreifen einsetzen; volle orange Flächen nur als bewusster Blickfang (Titelblatt, Schlussaufruf), nicht als Dekor für beliebige Abschnitte; dazu höchstens eine blaue Förderverein-Fläche pro Seite (One Field Rule). +- **Don't** ganze Zeilen dauerhaft orange füllen, um offene Anmeldungen zu markieren – wären alle Kurse offen, wäre die Seite orange. +- **Don't** Bezirksfarben als Dekoration, Fläche oder Abschnittsfarbe nutzen. +- **Don't** Kicker- oder Eyebrow-Labels über Überschriften setzen; Metadaten gehören unter den Titel. +- **Don't** Abschnitts- oder Listenköpfe in gesperrten Versalien setzen; sie sprechen schmal-extrafett. +- **Don't** Weiß auf Orange oder Druckorange als Text auf Papier setzen. +- **Don't** Seitenköpfe in Bezirksfarben oder Orange einfärben oder eine zweite Titelleiste einblenden. +- **Don't** mehr als einen Kasten pro Seite setzen oder Etiketten wie Schaltflächen umranden. +- **Don't** Fördervereinsblau als Text auf Papier oder für Inhalte außerhalb des Fördervereins verwenden. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..8f9e4a33 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,87 @@ +# Product + + + +## Platform + +web + +(Installierbare PWA mit handgeschriebenem Service Worker und Offline-Seite; Spiele laufen im Standalone-Modus mit Safe-Area-Unterstützung. Es gibt keine native App.) + +## Users + +**Hauptzielgruppe: aktive Chormitglieder.** Bläserinnen und Bläser sowie Chorleitungen aus den Posaunenchören der Evangelischen Kirche im Rheinland (13 Bezirke). Sie kommen mit einer konkreten Aufgabe: einen Termin nachsehen, sich zu einem Lehrgang anmelden (inkl. Anzahlung und Rechnung), die eigene Anmeldung verwalten, Materialien und Noten finden, das Rheinische Blechblatt lesen oder mit den Spielen üben. Viele nutzen die Seite unterwegs auf dem Handy, und die Altersspanne reicht von Jungbläser:innen bis zu langjährigen, älteren Mitgliedern. + +Weitere bestätigte Zielgruppen, bei Designentscheidungen nachrangig: + +- **Neue und Interessierte** (Eltern, Kinder und Jugendliche, Wiedereinsteiger:innen), die über „Mitmachen“ einen Chor in der Nähe, die Jungbläserarbeit oder Bildungsangebote finden. +- **Das Dashboard-Team:** Landesposaunenwarte, Regionalposaunenwarte, die Geschäftsstelle, Obleute und Ehrenamtliche. Sie pflegen Termine, Kurse, Beiträge, Ensembles, Personen, Newsletter, Rechnungen und Anmeldungen und geben Inhalte frei. +- **Förderer:** Mitglieder und mögliche Mitglieder des Fördervereins Rheinisches Posaunenwerk. + +## Product Purpose + +Die Plattform ist das Zuhause der evangelischen Bläserarbeit im Rheinland. Sie verbindet den öffentlichen Auftritt des Posaunenwerks (Termine, Lehrgänge, Aktuelles, Materialien, Struktur, Prävention, Förderverein) mit den Werkzeugen, mit denen der Verband seine Arbeit organisiert. + +Erfolg bemisst sich an vier gleichrangigen Zielen: + +1. **Lehrgangsanmeldungen:** mehr und reibungslosere Anmeldungen zu Kursen und Lehrgängen, von der Anmeldung über die Anzahlung bis zur Rechnung. +2. **Nachwuchs und Chorsuche:** mehr Menschen finden einen Posaunenchor und steigen in die Bläserarbeit ein, besonders Kinder und Jugendliche. +3. **Weniger Verwaltungsaufwand:** Geschäftsstelle und Ehrenamtliche sparen Zeit beim Pflegen, Freigeben und Kommunizieren. +4. **Sichtbarkeit und Bindung:** Aktuelles, Newsletter, Blechblatt, Spiele und Förderverein halten die Gemeinschaft über die Bezirke hinweg zusammen. + +## Positioning + +Die offizielle Plattform des Posaunenwerks der Evangelischen Kirche im Rheinland e.V. Andere Musikseiten können das nicht glaubwürdig nachahmen: Termine, Lehrgänge, Chorverzeichnis, Bezirksstruktur, Materialien des Verbands und die Lern-Spiele für Blechbläser kommen aus einer Hand und aus derselben Datenbank, gepflegt von den Menschen, die die Bläserarbeit tatsächlich tragen. Die Plattform ist zugleich Schaufenster und Arbeitsmittel des Verbands. Leitmotiv laut Startseite: „Gemeinsam Musik machen, Glauben leben“. + +## Operating Context + +- **Öffentliche Seite (Deutsch, `lang="de"`):** Start, Termine, Aktuelles, Mitmachen (Chor finden mit Karte, Aus- und Weiterbildung, Jungbläserarbeit, Ehrenamt), Materialien (Rheinisches Blechblatt, Literatur & CDs), Spiele, Über uns (Struktur & Geschichte, Vorstand, Posaunenwarte, Bezirke & Obleute, Auswahlchöre, Posaunenrat), Förderverein, Prävention, Newsletter, Suche, Kontakt, Impressum, Datenschutz, Lizenzen. +- **Konto-Bereich:** Registrierung, Login mit Zwei-Faktor-Authentifizierung, eigene Anmeldungen, Anmeldung per Link verwalten (`/anmeldung-verwalten`), Einstellungen. +- **Dashboard:** Inhaltspflege für alle Entitäten, Freigabe-Workflow (DRAFT → PENDING → APPROVED), Kursverwaltung mit Teilnehmenden, individuellen Anmeldefeldern, Preisoptionen, Anzahlungen, Rechnungen und Kurs-Mails, Newsletter, Medienbibliothek mit Freigabe, Startseiten-Karussell, Statistik, Audit-Log, Rollen und Rechte (hierarchische Rollen, Bezirks-Zuständigkeiten), Export/Import, Wartungsmodus, Social-Media-Export für Instagram. +- **Spiele:** Rhythmus, Noten lesen, Griffe (Ventile/Zugpositionen) und Notenwaage, kostenlos im Browser und offline nutzbar. Die Statistik liegt zuerst lokal und wird bei Anmeldung synchronisiert. Öffentliche Notensets gibt es auch. +- **Rituale und Kanäle:** Lehrgangssaison mit Anmeldefristen und Anzahlungen, Fördervereinstag, Rheinisches Blechblatt als Magazin, Newsletter mit Double-Opt-in, RSS-Feed für Aktuelles, Social Media (Facebook, Instagram, YouTube). +- **Umgebungen:** Die Produktion läuft auf mittwald. Eine Vorab-Umgebung zeigt ein Beta-Banner (`APP_ENV` ≠ `production`), auf ihr ist die Feedback-Seite aktiv. + +## Capabilities and Constraints + +- **Stack (bestehend):** Next.js 16 (App Router, Turbopack), TypeScript, tRPC, Prisma/PostgreSQL, Better Auth, Tailwind CSS 4, React Query. Tiptap für Rich-Text, Leaflet für Karten, Recharts für Statistiken, VexFlow (nur `vexflow/bravura`) für Notensatz. +- **Hell- und Dunkelmodus** werden beide unterstützt (Systemeinstellung oder manuelle Wahl). +- **DSGVO:** Tracking nur mit Einwilligung, Datenexport und Löschwege für Admins (siehe `docs/DSGVO-COMPLIANCE.md`). +- **Kinderschutz** ist Pflichtthema: Präventionsseite, Selbstverpflichtung und Zusatzerklärung für Minderjährige. +- **Rollen:** Landesposaunenwart (LPW) gibt alles frei, Regionalposaunenwart (RPW) die zugewiesenen Bezirke, Obleute erstellen Inhalte, die freigegeben werden müssen. Die Rechte sind feingranular und hierarchisch. +- **Fachbegriffe beibehalten:** Posaunenchor, Bläser:innen, Jungbläser, Lehrgang, Bezirk, Obleute, Posaunenwart (LPW/RPW), Posaunenrat, Auswahlchor, Bläserheft, Blechblatt, Förderverein. +- **Offen:** Die Anrede ist nicht festgelegt. Öffentliche Texte duzen meist („Finde einen Posaunenchor …“), einzelne Stellen siezen („für Ihren Posaunenchor“). Das ist noch zu entscheiden. + +## Brand Commitments + +Verbindlich sind nur diese Vorgaben, alles andere am Erscheinungsbild ist frei: + +- **Logo:** `public/images/logo-horizontal.svg`, `logo-horizontal-dark.svg`, `logo-icon.svg`, `logo-icon-dark.svg`, `logo.png`, dazu App-Icons in `public/` (inkl. maskable). +- **Primärfarbe:** `#faa619` (Token `--color-primary` in `src/styles/globals.css`). +- **Fördervereinsfarbe:** `#78a8da` (Token `--color-foerderverein`). Sie kennzeichnet den Förderverein. +- **Name:** „Posaunenwerk Rheinland“. Die rechtliche Bezeichnung lautet „Posaunenwerk der Evangelischen Kirche im Rheinland e.V.“, der Förderverein heißt „Förderverein Rheinisches Posaunenwerk“. + +## Evidence on Hand + +- **Echte Organisationsdaten:** Anschrift, Telefon, E-Mail und Social-Media-Profile in `src/lib/structured-data.ts` (synchron mit dem Impressum). +- **Offizielle Dokumente** in `public/downloads/`: Satzung, Ehrenordnung, Aufnahmeantrag für Chöre, Arbeitshilfe Jungbläser, Leistungsstempel, Förderverein-Flyer, Selbstverpflichtung Kinderschutz, Zusatzerklärung Minderjährige. +- **Bilder** von Personen und Gremien in `public/images/` (team, vorstand, posaunenwarte, bezirke, auswahlchoere, blaeserhefte). Hinzu kommen hochgeladene Medien in der Datenbank mit Bildnachweisen. +- **Audio:** `public/audio/frankreich-2021-sample.mp3`. +- **Live-Inhalte:** Termine, Lehrgänge, Beiträge, Chöre, Bezirke und Personen stehen in der Datenbank. +- **Bestätigte Kennzahlen** (Wortlaut auf den Seiten, vom Nutzer als Inhalt bestätigt): annähernd 200 Posaunenchöre, über 2.000 aktive Bläserinnen und Bläser, über 140 Jahre Posaunenchorarbeit im Rheinland, 13 Bezirke, Förderverein seit 2008 (`src/app/ueber-uns/struktur/page.tsx`, `src/app/mitmachen/page.tsx`, `src/app/mitmachen/chor-finden/page.tsx`). +- **Fotos:** Lokal gibt es nicht genug echte Fotos für eine bildgeführte Gestaltung (Medienbibliothek der Dev-Umgebung plus Porträts in `public/images/`). Der Nutzer liefert Fotos nach, braucht dafür aber genaue Beschreibungen, was gewollt ist (Motiv, Ausschnitt, Format, Einsatzort). +- **Nicht vorhanden, darf nicht erfunden werden:** Testimonials, Wirkungsstatistiken und Zitate. `news-placeholder-*.jpg` und `profile-placeholder.jpg` sind Platzhalter, keine echten Fotos. + +## Product Principles + +1. **Chormitglieder zuerst.** Wer schon dabei ist, soll Termin, Lehrgang, Anmeldung oder Material ohne Umweg finden. Diese Aufgaben haben Vorrang vor Selbstdarstellung. +2. **Anmelden muss leicht sein.** Jeder Schritt vom Lehrgang zur bestätigten Anmeldung (samt Anzahlung und Rechnung) muss auch für ungeübte und ältere Menschen auf dem Handy klar und fehlertolerant sein. +3. **Die Tür für Neue offen halten.** Kinder, Eltern und Wiedereinsteiger:innen verstehen die Fachsprache des Verbands nicht automatisch. Einstiege erklären statt voraussetzen. +4. **Ehrenamt entlasten.** Jede Dashboard-Funktion soll Aufwand sparen und Fehler verhindern, denn die Pflegenden haben wenig Zeit und unterschiedliche technische Erfahrung. +5. **Eine Gemeinschaft, viele Bezirke.** Die Bezirke, Chöre und Menschen sollen sichtbar sein. Die Plattform verbindet, statt nur zu verwalten. + +## Accessibility & Inclusion + +- **Pflicht:** Barrierefreiheit nach WCAG bzw. BITV. Die genaue Konformitätsstufe ist nicht benannt. BITV 2.0 verweist über EN 301 549 auf WCAG 2.1 AA, das gilt deshalb bis zur Klärung als Mindestmaß. +- Die Nutzenden sind von Kindern bis zu Senior:innen sehr gemischt. Darum sind gut lesbare Schrift, ausreichende Kontraste in Hell- und Dunkelmodus, große Touch-Ziele und klare Fehlermeldungen wichtig. +- Die Spiele setzen auf Hören, Timing und Notenlesen. Sie brauchen Alternativen, wo das möglich ist (z. B. visuelles Metronom), und müssen reduzierte Bewegung respektieren. diff --git a/public/images/logo-icon-ink.png b/public/images/logo-icon-ink.png new file mode 100644 index 00000000..e89d0983 Binary files /dev/null and b/public/images/logo-icon-ink.png differ diff --git a/scripts/preview-emails.ts b/scripts/preview-emails.ts new file mode 100644 index 00000000..e90dca2d --- /dev/null +++ b/scripts/preview-emails.ts @@ -0,0 +1,416 @@ +/** + * Rendert E-Mail-Vorlagen lokal nach HTML, damit man sie ansehen kann. + * Nur für die Entwicklung. pnpm exec tsx scripts/preview-emails.ts + */ +import { createElement } from "react"; +import { render } from "@react-email/components"; +import { writeFileSync, mkdirSync } from "fs"; +import { + CourseRegistrationConfirmed, + courseRegistrationConfirmedText, +} from "../src/server/email/templates/course-registration-confirmed"; +import { + CourseRegistrationWaitlist, + courseRegistrationWaitlistText, +} from "../src/server/email/templates/course-registration-waitlist"; +import { + CourseRegistrationCancelled, + courseRegistrationCancelledText, +} from "../src/server/email/templates/course-registration-cancelled"; +import { + CourseRegistrationPendingDiscount, + courseRegistrationPendingDiscountText, +} from "../src/server/email/templates/course-registration-pending-discount"; +import { + VerificationEmail, + verificationEmailText, +} from "../src/server/email/templates/verification-email"; +import { + NewsletterConfirm, + newsletterConfirmText, +} from "../src/server/email/templates/newsletter-confirm"; +import { PasswordResetEmail } from "../src/server/email/templates/password-reset-email"; +import { + NewsletterEmail, + newsletterEmailText, +} from "../src/server/email/templates/newsletter"; +import { + ContactMessage, + contactMessageText, +} from "../src/server/email/templates/contact-message"; +import { + ContentReviewResult, + contentReviewResultText, +} from "../src/server/email/templates/content-review-result"; +import { + generateNewsletterHtml, + generateNewsletterText, +} from "../src/server/email/templates/newsletter-html"; +import { + generateCourseMailHtml, + generateCourseMailText, +} from "../src/server/email/templates/course-mail-html"; +import { + SiblingDiscountApproved, + siblingDiscountApprovedText, +} from "../src/server/email/templates/sibling-discount-approved"; +import { + SiblingDiscountRejected, + siblingDiscountRejectedText, +} from "../src/server/email/templates/sibling-discount-rejected"; +import { + RegistrationAccessLinks, + registrationAccessLinksText, +} from "../src/server/email/templates/registration-access-links"; +import { + CourseRegistrationClosedOverview, + courseRegistrationClosedOverviewText, +} from "../src/server/email/templates/course-registration-closed-overview"; +import type { DownPaymentMailInfo } from "../src/server/email/down-payment"; +import type { CourseRegistrationStats } from "../src/lib/course-participants-export"; + +const OUT = + "/private/tmp/claude-501/-Users-lars-Develompent-pwr-web/37912ebe-1f84-4649-8d3d-671da6870c0a/scratchpad/mails"; +mkdirSync(OUT, { recursive: true }); + +/** Anzahlung noch offen, jetzt fällig — zeigt Bankdaten und (Platzhalter-)GiroCode. */ +const downPaymentFaellig: DownPaymentMailInfo = { + amount: 50, + reference: "K-2026-10 Test, Claude", + refundNotice: + "Bei Stornierung bis 4 Wochen vor Kursbeginn erstatten wir die Anzahlung vollständig.", + dueNow: true, + alreadyPaid: false, + beneficiary: "Posaunenwerk der Evangelischen Kirche im Rheinland e.V.", + iban: "DE12 3456 7890 1234 5678 90", + bic: "GENODED1XXX", + bankName: "KD-Bank", +}; + +/** Anzahlung noch nicht fällig — Warteliste, Betrag steht, Bankdaten fehlen noch. */ +const downPaymentNichtFaellig: DownPaymentMailInfo = { + ...downPaymentFaellig, + dueNow: false, +}; + +/** Anzahlung schon eingegangen — z. B. vom Team bei der Erfassung verbucht. */ +const downPaymentBezahlt: DownPaymentMailInfo = { + ...downPaymentFaellig, + alreadyPaid: true, +}; + +const vorlagen = [ + [ + "anmeldung-bestaetigt", + CourseRegistrationConfirmed, + courseRegistrationConfirmedText, + { + registrantFirstName: "Claude", + registrantLastName: "Test", + courseTitle: "Lehrgang für Jungbläser*innen", + startDate: new Date("2026-10-02"), + endDate: new Date("2026-10-05"), + totalPrice: 155, + participantsCount: 2, + registrationId: "31352115-b789-4bb7-b9ee-938ce1414a71", + manageUrl: "https://example.org/anmeldung-verwalten?token=abc", + downPayment: downPaymentFaellig, + downPaymentHasQr: true, + }, + ], + [ + "auf-warteliste", + CourseRegistrationWaitlist, + courseRegistrationWaitlistText, + { + registrantFirstName: "Claude", + registrantLastName: "Test", + courseTitle: "Lehrgang für Jungbläser*innen", + startDate: new Date("2026-10-02"), + endDate: new Date("2026-10-05"), + totalPrice: 155, + participantsCount: 2, + registrationId: "31352115-b789-4bb7-b9ee-938ce1414a71", + manageUrl: "https://example.org/anmeldung-verwalten?token=abc", + downPayment: downPaymentNichtFaellig, + }, + ], + [ + "anmeldung-storniert", + CourseRegistrationCancelled, + courseRegistrationCancelledText, + { + registrantFirstName: "Claude", + registrantLastName: "Test", + courseTitle: "Lehrgang für Jungbläser*innen", + startDate: new Date("2026-10-02"), + endDate: new Date("2026-10-05"), + participantsCount: 2, + registrationId: "31352115-b789-4bb7-b9ee-938ce1414a71", + }, + ], + [ + "anmeldung-rabatt-pruefung", + CourseRegistrationPendingDiscount, + courseRegistrationPendingDiscountText, + { + registrantFirstName: "Claude", + registrantLastName: "Test", + courseTitle: "Lehrgang für Jungbläser*innen", + startDate: new Date("2026-10-02"), + endDate: new Date("2026-10-05"), + originalTotalPrice: 310, + discountAmount: 62, + finalTotalPrice: 248, + participantsCount: 2, + registrationId: "31352115-b789-4bb7-b9ee-938ce1414a71", + manageUrl: "https://example.org/anmeldung-verwalten?token=abc", + downPayment: downPaymentBezahlt, + downPaymentHasQr: false, + }, + ], + [ + "bestaetigung", + VerificationEmail, + verificationEmailText, + { + verificationUrl: "https://example.org/verify?token=abc", + userName: "Claude Test", + }, + ], + [ + "newsletter-bestaetigen", + NewsletterConfirm, + newsletterConfirmText, + { + confirmUrl: "https://example.org/newsletter/bestaetigen?token=abc", + subscriberName: "Claude", + }, + ], + [ + "passwort-zuruecksetzen", + PasswordResetEmail, + null, + { + resetUrl: "https://example.org/reset-password?token=abc", + userName: "Claude Test", + }, + ], + [ + "geschwisterkindrabatt-genehmigt", + SiblingDiscountApproved, + siblingDiscountApprovedText, + { + registrantFirstName: "Claude", + registrantLastName: "Test", + courseTitle: "Lehrgang für Jungbläser*innen", + startDate: new Date("2026-10-02"), + endDate: new Date("2026-10-05"), + originalTotalPrice: 310, + discountAmount: 62, + finalTotalPrice: 248, + participantsCount: 2, + registrationId: "31352115-b789-4bb7-b9ee-938ce1414a71", + manageUrl: "https://example.org/anmeldung-verwalten?token=abc", + }, + ], + [ + "geschwisterkindrabatt-abgelehnt", + SiblingDiscountRejected, + siblingDiscountRejectedText, + { + registrantFirstName: "Claude", + registrantLastName: "Test", + courseTitle: "Lehrgang für Jungbläser*innen", + startDate: new Date("2026-10-02"), + endDate: new Date("2026-10-05"), + originalTotalPrice: 310, + participantsCount: 2, + registrationId: "31352115-b789-4bb7-b9ee-938ce1414a71", + manageUrl: "https://example.org/anmeldung-verwalten?token=abc", + }, + ], + [ + "anmeldungen-zugang", + RegistrationAccessLinks, + registrationAccessLinksText, + { + registrantFirstName: "Claude", + registrations: [ + { + courseTitle: "Lehrgang für Jungbläser*innen", + startDate: new Date("2026-10-02"), + endDate: new Date("2026-10-05"), + statusLabel: "Bestätigt", + participantsCount: 2, + manageUrl: "https://example.org/anmeldung-verwalten?token=abc1", + }, + { + courseTitle: "Bläserfreizeit Herbst", + startDate: new Date("2026-11-06"), + endDate: new Date("2026-11-08"), + statusLabel: "Warteliste", + participantsCount: 1, + manageUrl: "https://example.org/anmeldung-verwalten?token=abc2", + }, + ], + }, + ], + [ + "anmeldefrist-beendet", + CourseRegistrationClosedOverview, + courseRegistrationClosedOverviewText, + { + courseTitle: "Lehrgang für Jungbläser*innen", + registrationDeadline: new Date("2026-09-20T18:00:00"), + startDate: new Date("2026-10-02"), + endDate: new Date("2026-10-05"), + locationName: "Freizeitheim Vallendar", + maxParticipants: 40, + allowWaitingList: true, + stats: { + confirmedParticipants: 34, + waitlistParticipants: 6, + cancelledParticipants: 2, + activeRegistrations: 20, + pendingDiscountRegistrations: 3, + totalRevenueConfirmed: 5270, + paidRevenue: 3100, + downPaymentsReceived: 1200, + downPaymentsOpen: 400, + refundPendingRegistrations: 1, + } satisfies CourseRegistrationStats, + participantsUrl: "https://example.org/dashboard/kurse/abc/teilnehmer", + }, + ], + [ + "newsletter", + NewsletterEmail, + newsletterEmailText, + { + content: + "

Liebe Bläserinnen und Bläser,

der Herbstlehrgang ist ausgebucht — die Warteliste läuft noch bis Ende der Woche.

", + unsubscribeUrl: "https://example.org/newsletter/abmelden?token=abc", + subscriberName: "Claude", + }, + ], + [ + "kontaktformular", + ContactMessage, + contactMessageText, + { + name: "Claude Test", + email: "claude@example.org", + phone: "0261 1234567", + subjectLabel: "Allgemeine Anfrage", + message: + "Hallo,\n\nich wollte fragen, ob es noch freie Plätze im Herbstlehrgang gibt.\n\nViele Grüße\nClaude", + }, + ], + [ + "pruefergebnis-veroeffentlicht", + ContentReviewResult, + contentReviewResultText, + { + recipientName: "Claude", + contentType: "course", + title: "Lehrgang für Jungbläser*innen", + approved: true, + reviewNotes: null, + dashboardUrl: "https://example.org/dashboard/kurse/abc", + }, + ], + [ + "pruefergebnis-abgelehnt", + ContentReviewResult, + contentReviewResultText, + { + recipientName: "Claude", + contentType: "post", + title: "Rückblick: Landesposaunentag 2026", + approved: false, + reviewNotes: + "Bitte noch ein Titelbild ergänzen und die Quellen der Zitate nennen.", + dashboardUrl: "https://example.org/dashboard/beitraege/abc", + }, + ], +] as const; + +/** Die beiden HTML-Zeichenketten-Bauer — kein react-email, direkter Aufruf. */ +const htmlBauer = [ + [ + "newsletter-html", + () => + generateNewsletterHtml({ + content: + "

Liebe Bläserinnen und Bläser,

der Herbstlehrgang ist ausgebucht — die Warteliste läuft noch bis Ende der Woche.

", + unsubscribeUrl: "https://example.org/newsletter/abmelden?token=abc", + subscriberName: "Claude", + }), + () => + generateNewsletterText({ + unsubscribeUrl: "https://example.org/newsletter/abmelden?token=abc", + subscriberName: "Claude", + }), + ], + [ + "kursmail-html", + () => + generateCourseMailHtml({ + bodyHtml: + "

bitte denkt daran, festes Schuhwerk und eine Regenjacke einzupacken.

Wir freuen uns auf euch!

", + courseTitle: "Lehrgang für Jungbläser*innen", + courseStartDate: new Date("2026-10-02"), + courseEndDate: new Date("2026-10-05"), + recipientName: "Claude", + senderName: "Anna Organisatorin", + replyToEmail: "anna@example.org", + courseUrl: "https://example.org/kurse/lehrgang-jungblaeser", + includeGreeting: true, + }), + () => + generateCourseMailText({ + courseTitle: "Lehrgang für Jungbläser*innen", + courseStartDate: new Date("2026-10-02"), + courseEndDate: new Date("2026-10-05"), + recipientName: "Claude", + senderName: "Anna Organisatorin", + replyToEmail: "anna@example.org", + courseUrl: "https://example.org/kurse/lehrgang-jungblaeser", + includeGreeting: true, + }), + ], +] as const; + +async function main() { + for (const [name, Komponente, textFn, props] of vorlagen) { + const html = await render( + createElement(Komponente as never, props as never), + ); + writeFileSync(`${OUT}/${name}.html`, html); + console.log(`gerendert: ${name}.html (${html.length} Zeichen)`); + + if (textFn) { + const text = (textFn as (p: never) => string)(props as never); + console.log(`\n----- Nur-Text-Fassung: ${name} -----`); + console.log(text); + console.log(`----- Ende: ${name} -----\n`); + } + } + + for (const [name, htmlFn, textFn] of htmlBauer) { + const html = htmlFn(); + writeFileSync(`${OUT}/${name}.html`, html); + console.log(`gerendert: ${name}.html (${html.length} Zeichen)`); + + const text = textFn(); + console.log(`\n----- Nur-Text-Fassung: ${name} -----`); + console.log(text); + console.log(`----- Ende: ${name} -----\n`); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/_components/dashboard/address-autocomplete.tsx b/src/app/_components/dashboard/address-autocomplete.tsx index b97481dc..1499e8b8 100644 --- a/src/app/_components/dashboard/address-autocomplete.tsx +++ b/src/app/_components/dashboard/address-autocomplete.tsx @@ -25,7 +25,7 @@ const MIN_QUERY_LENGTH = 3; const DEBOUNCE_MS = 350; const defaultInputClass = - "focus:border-primary focus:ring-primary dark:border-dark-border dark:bg-dark-background-secondary dark:text-dark-text block w-full rounded-lg border border-gray-300 bg-white py-2 pr-9 pl-9 text-gray-900 focus:ring-1 focus:outline-none"; + "border-ink dark:border-night-text dark:bg-night dark:text-night-text block w-full border bg-paper py-2 pr-9 pl-9"; /** Verzögert den Wert, damit nicht jeder Tastendruck eine Anfrage auslöst. */ function useDebouncedValue(value: T, delayMs: number): T { @@ -136,7 +136,7 @@ export function AddressAutocomplete({ {label ? ( @@ -144,7 +144,7 @@ export function AddressAutocomplete({
{isFetching && isSearchable ? ( ) : null}
{hint ? ( -

- {hint} -

+

{hint}

) : null} {showDropdown ? (
{results.length > 0 ? ( results.map((suggestion, index) => ( @@ -199,19 +197,19 @@ export function AddressAutocomplete({ className={cn( "flex w-full items-start gap-2 px-3 py-2 text-left text-sm", index === activeIndex - ? "bg-gray-100 dark:bg-gray-700" - : "hover:bg-gray-100 dark:hover:bg-gray-700", + ? "bg-rule/30 dark:bg-night-raised" + : "hover:bg-rule/30 dark:hover:bg-night-raised", )} > - + {suggestion.name ?? suggestion.street ?? suggestion.city} - + {[ suggestion.name ? suggestion.street : null, [suggestion.zipCode, suggestion.city] @@ -225,7 +223,7 @@ export function AddressAutocomplete({ )) ) : ( -
+
{isFetching ? "Suche läuft …" : "Keine Adresse gefunden"}
)} diff --git a/src/app/_components/dashboard/content-status.tsx b/src/app/_components/dashboard/content-status.tsx index e233e3b8..09ad1449 100644 --- a/src/app/_components/dashboard/content-status.tsx +++ b/src/app/_components/dashboard/content-status.tsx @@ -1,4 +1,5 @@ import type { ContentStatus } from "~/generated/prisma/enums"; +import { Tag, type TagTone } from "@/app/_components/programmheft/tag"; /** * Ein Freigabestatus, eine Beschriftung, eine Farbe — Karten, Tabellen und @@ -13,14 +14,25 @@ export const CONTENT_STATUS_LABELS: Record = { ARCHIVED: "Archiviert", }; -export const CONTENT_STATUS_BADGE_CLASSES: Record = { - DRAFT: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", - PENDING: - "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300", - APPROVED: - "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", - REJECTED: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", - ARCHIVED: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", +/** + * Der Ton trägt die Dringlichkeit, nicht die Identität — den Namen des + * Zustands liest man ohnehin. + * + * Gefüllt heißt „das musst du sehen": Zur Prüfung wartet auf dich (Orange), + * Veröffentlicht ist live (Tinte), Abgelehnt ist ein negativer Abschluss + * (Rot). Umrandet heißt „das ist nur der Stand": Entwurf und Archiviert + * verlangen nichts von dir und teilen sich deshalb den zurückgenommenen Ton. + * + * Vorher lagen Entwurf, Abgelehnt und Archiviert alle auf `inverse` und waren + * damit ununterscheidbar — „Abgelehnt" verlor seine Bedeutung. Dafür hat + * `Tag` den umrandeten `muted`-Ton bekommen. + */ +const CONTENT_STATUS_TONE: Record = { + DRAFT: "muted", + PENDING: "orange", + APPROVED: "ink", + REJECTED: "cancelled", + ARCHIVED: "muted", }; /** Set-Filter-Optionen für eine Statusspalte. */ @@ -36,10 +48,8 @@ export function ContentStatusBadge({ className?: string; }) { return ( - + {CONTENT_STATUS_LABELS[status]} - + ); } diff --git a/src/app/_components/dashboard/course-custom-fields-editor.tsx b/src/app/_components/dashboard/course-custom-fields-editor.tsx index 9b1cd034..7bb42dcf 100644 --- a/src/app/_components/dashboard/course-custom-fields-editor.tsx +++ b/src/app/_components/dashboard/course-custom-fields-editor.tsx @@ -18,6 +18,7 @@ import { parseSelectOptionValues, } from "@/lib/course-custom-fields"; import { Select } from "@/app/_components/ui"; +import { Tag } from "@/app/_components/programmheft/tag"; import { CustomFieldType } from "~/generated/prisma/enums"; export const customFieldTypeLabels: Record = { @@ -52,7 +53,7 @@ type CourseCustomFieldsEditorProps = { }; const inputClassName = - "focus:border-primary focus:ring-primary dark:border-dark-border dark:bg-dark-background-secondary dark:text-dark-text dark:disabled:bg-dark-background block w-full rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:text-gray-500"; + "border-ink dark:border-night-text dark:bg-night dark:text-night-text block w-full border bg-paper px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"; const normalizeName = (name: string) => name.trim().toLowerCase(); @@ -214,7 +215,7 @@ export function CourseCustomFieldsEditor({ @@ -230,13 +231,13 @@ export function CourseCustomFieldsEditor({ ) : null} {libraryOpen && !disabled ? ( -
+
-

+

Feld-Bibliothek

-

+

Beim Hinzufügen wird eine Kopie in den Kurs übernommen. Eigene Felder sind erst für alle sichtbar, sobald ein Kurs mit dem Feld freigegeben wurde. @@ -245,7 +246,7 @@ export function CourseCustomFieldsEditor({

{templatesLoading ? ( -

+

Lade Bibliothek…

) : !templates || templates.length === 0 ? ( -

+

Noch keine Felder in der Bibliothek. Speichere ein Feld über das Lesezeichen-Symbol, um es hier für alle verfügbar zu machen.

@@ -270,24 +271,24 @@ export function CourseCustomFieldsEditor({ return (
  • -

    +

    {template.fieldName} {template.isRequired ? ( * ) : null} {!template.isGlobal ? ( - Privat + Privat ) : null}

    -

    +

    {customFieldTypeLabels[template.fieldType]} {customFieldTypeNeedsOptions(template.fieldType) ? `: ${parseSelectOptionValues(template.options).join(", ")}` @@ -300,7 +301,7 @@ export function CourseCustomFieldsEditor({ type="button" onClick={() => addFromLibrary(template)} disabled={alreadyAdded} - className="text-primary hover:text-primary/80 shrink-0 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-40" + className="text-primary-ink dark:text-primary shrink-0 text-sm font-medium hover:underline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:no-underline" > {alreadyAdded ? "Hinzugefügt" : "+ Hinzufügen"} @@ -316,7 +317,7 @@ export function CourseCustomFieldsEditor({ } }} disabled={deleteTemplate.isPending} - className="shrink-0 p-1 text-gray-400 hover:text-red-500 disabled:opacity-40" + className="text-dark dark:text-night-muted shrink-0 p-1 hover:text-red-500 disabled:opacity-40" aria-label={`${template.fieldName} aus Bibliothek löschen`} > @@ -330,7 +331,7 @@ export function CourseCustomFieldsEditor({ ) : null} {fields.length === 0 ? ( -

    +

    Keine zusätzlichen Felder definiert.

    ) : ( @@ -340,21 +341,21 @@ export function CourseCustomFieldsEditor({ return (
    - + Feld {index + 1} {!disabled && (
    {inLibrary ? ( @@ -367,7 +368,7 @@ export function CourseCustomFieldsEditor({ createTemplate.isPending && savingFieldId === field.id } - className="hover:text-primary p-1 text-gray-400 disabled:opacity-40" + className="text-dark dark:text-night-muted hover:text-primary-ink dark:hover:text-primary p-1 disabled:opacity-40" title="In Bibliothek speichern (für alle verfügbar)" > @@ -377,7 +378,7 @@ export function CourseCustomFieldsEditor({ type="button" onClick={() => moveField(field.id, "up")} disabled={index === 0} - className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30" + className="text-dark dark:text-night-muted hover:text-ink dark:hover:text-night-text p-1 disabled:opacity-30" > @@ -385,14 +386,14 @@ export function CourseCustomFieldsEditor({ type="button" onClick={() => moveField(field.id, "down")} disabled={index === fields.length - 1} - className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30" + className="text-dark dark:text-night-muted hover:text-ink dark:hover:text-night-text p-1 disabled:opacity-30" > @@ -402,7 +403,7 @@ export function CourseCustomFieldsEditor({
    -
    -