Files
Wabenhain/src/lib/content-registry.ts
T
Nick SzaunigandClaude Opus 5.5 4d1bcbbdb7 feat(invoice): Stornorechnung und Rechnungskopie an Admin-Adresse
Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich
eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich
als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts).

Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin,
erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine
Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen
Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an
die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und
Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung.
Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen.

ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit
Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern.
Admin-Bestelldetail zeigt die Stornorechnung mit Download.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:19:42 +02:00

154 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getDictionary } from "@/i18n/get-dictionary";
import { siteConfig } from "@/lib/site-config";
import { DEFAULT_SHIPPING_REGION } from "@/lib/shipping-region";
import type { Locale } from "@/i18n/config";
import type { TitledText, LegalSectionContent } from "@/i18n/dictionary-type";
export type ContentFieldType =
| "text"
| "textarea"
/** Feste Auswahl statt freiem Text — siehe "options" an ContentFieldDef. */
| "select"
| "list-simple"
| "list-titled"
| "list-numbered"
| "richtext-sections"
| "image"
| "video";
export interface ContentFieldDef {
key: string;
group: "brand" | "company" | "shipping" | "home" | "pages" | "legal" | "social";
labelDe: string;
labelEn: string;
type: ContentFieldType;
/** false = ein einziger Wert unabhaengig von der Locale (Firmendaten). */
localized: boolean;
/** Liefert den aktuellen Fallback-Text im selben Format, das der Parser fuer diesen Typ erwartet. */
getDefault: (locale: Locale) => string;
/**
* Nur bei type: "select" — die waehlbaren Werte. Ein freies Textfeld waere
* hier falsch: ein Tippfehler im Liefergebiet wuerde stillschweigend auf
* den Vorgabewert zurueckfallen, und niemand saehe, dass die Umstellung
* nicht gegriffen hat.
*/
options?: { value: string; labelDe: string; labelEn: string }[];
}
function joinSimpleList(items: string[]): string {
return items.join("\n");
}
function joinTitledList(items: TitledText[]): string {
return items.map((i) => `${i.title} | ${i.text}`).join("\n");
}
function joinNumberedList(items: { number: string; title: string; text: string }[]): string {
return items.map((i) => `${i.number} | ${i.title} | ${i.text}`).join("\n");
}
function joinSections(sections: LegalSectionContent[]): string {
return sections
.map((s) => `## ${s.heading}\n\n${s.paragraphs.join("\n\n")}`)
.join("\n\n");
}
export const CONTENT_REGISTRY: ContentFieldDef[] = [
// --- Logo und Favicon (nicht lokalisiert). Leer = das gezeichnete
// Waben-Zeichen (components/logomark.tsx) bzw. das Standard-Icon
// (lib/brand-icon.ts). Nur Rasterbilder: der Upload nimmt bewusst kein SVG
// an (ein SVG kann Skripte enthalten). Gelesen ueber lib/brand.ts.
{ key: "brand.logo", group: "brand", labelDe: "Logo (PNG mit transparentem Hintergrund, erscheint neben dem Firmennamen)", labelEn: "Logo (PNG with transparent background, shown next to the company name)", type: "image", localized: false, getDefault: () => "" },
{ key: "brand.logoInverted", group: "brand", labelDe: "Logo für dunkle Flächen (optional: Mobilmenü, Fußzeile, Admin-Leiste; leer = normales Logo)", labelEn: "Logo for dark backgrounds (optional: mobile menu, footer, admin sidebar; empty = regular logo)", type: "image", localized: false, getDefault: () => "" },
{ key: "brand.favicon", group: "brand", labelDe: "Favicon (quadratisch, mindestens 512 × 512 px; leer = Standard-Wabe)", labelEn: "Favicon (square, at least 512 × 512 px; empty = default honeycomb)", type: "image", localized: false, getDefault: () => "" },
// --- Firmendaten (nicht lokalisiert) ---
{ key: "company.name", group: "company", labelDe: "Firmenname", labelEn: "Company name", type: "text", localized: false, getDefault: () => siteConfig.name },
{ key: "company.claim", group: "company", labelDe: "Slogan", labelEn: "Tagline", type: "text", localized: false, getDefault: () => siteConfig.claim },
{ key: "company.claimAlt", group: "company", labelDe: "Alternativer Slogan", labelEn: "Alternate tagline", type: "text", localized: false, getDefault: () => siteConfig.claimAlt },
{ key: "company.description", group: "company", labelDe: "Kurzbeschreibung", labelEn: "Short description", type: "textarea", localized: false, getDefault: () => siteConfig.description },
{ key: "company.location", group: "company", labelDe: "Standort (Anzeigetext)", labelEn: "Location (display text)", type: "text", localized: false, getDefault: () => siteConfig.location },
{ key: "company.email", group: "company", labelDe: "E-Mail", labelEn: "Email", type: "text", localized: false, getDefault: () => siteConfig.email },
{ key: "company.phone", group: "company", labelDe: "Telefon", labelEn: "Phone", type: "text", localized: false, getDefault: () => siteConfig.phone },
{ key: "company.vatId", group: "company", labelDe: "Umsatzsteuer-Identifikationsnummer (erscheint auf jeder Rechnung; in Texten per Baustein {{USt-IdNr}} einfügbar)", labelEn: "VAT identification number (shown on every invoice; insert into texts with the placeholder {{USt-IdNr}})", type: "text", localized: false, getDefault: () => siteConfig.vatId },
{ key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street },
{ key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip },
{ key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city },
// Kopie jeder Rechnung und Stornorechnung (lib/invoice-mail.ts). Leer = keine Kopie.
{ key: "invoice.copyEmail", group: "company", labelDe: "Rechnungskopie an (E-Mail-Adresse; jede Rechnung und Stornorechnung geht als E-Rechnung zusätzlich an diese Adresse, leer = keine Kopie)", labelEn: "Invoice copy to (email address; every invoice and cancellation invoice is also sent as an e-invoice to this address, empty = no copy)", type: "text", localized: false, getDefault: () => "" },
// --- Liefergebiet ---
// Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die
// verbindliche Pruefung beim Anlegen der Bestellung (api/checkout/route.ts)
// und den Hinweis auf der Seite "Versand und Zahlung". Bewusst eine
// Auswahl und kein Freitext — siehe "options" oben.
{
key: "shipping.region",
group: "shipping",
labelDe: "Liefergebiet",
labelEn: "Shipping region",
type: "select",
localized: false,
getDefault: () => DEFAULT_SHIPPING_REGION,
options: [
{ value: "DE", labelDe: "Nur Deutschland", labelEn: "Germany only" },
{ value: "EU", labelDe: "Europäische Union", labelEn: "European Union" },
],
},
// --- Homepage (lokalisiert) ---
{ key: "home.hero.intro", group: "home", labelDe: "Hero: Einleitungstext", labelEn: "Hero: Intro text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.hero.intro },
{ key: "home.hero.footnote", group: "home", labelDe: "Hero: Fußnote", labelEn: "Hero: Footnote", type: "text", localized: true, getDefault: (l) => getDictionary(l).home.hero.footnote },
{ key: "home.originStory.paragraph1", group: "home", labelDe: "Herkunft: Absatz 1", labelEn: "Origin: Paragraph 1", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.originStory.paragraph1 },
{ key: "home.originStory.paragraph2", group: "home", labelDe: "Herkunft: Absatz 2", labelEn: "Origin: Paragraph 2", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.originStory.paragraph2 },
{ key: "home.originStory.facts", group: "home", labelDe: "Herkunft: Fakten (eine Zeile je Fakt)", labelEn: "Origin: Facts (one per line)", type: "list-simple", localized: true, getDefault: (l) => joinSimpleList(getDictionary(l).home.originStory.facts) },
{ key: "home.philosophy.items", group: "home", labelDe: "Philosophie: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Philosophy: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.philosophy.items) },
{ key: "home.processTimeline.steps", group: "home", labelDe: "Ablauf: Schritte (Nr. | Titel | Text, eine Zeile je Schritt)", labelEn: "Process: Steps (No. | Title | Text, one per line)", type: "list-numbered", localized: true, getDefault: (l) => joinNumberedList(getDictionary(l).home.processTimeline.steps) },
{ key: "home.regionalSection.paragraph1", group: "home", labelDe: "Regional: Absatz 1", labelEn: "Regional: Paragraph 1", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.regionalSection.paragraph1 },
{ key: "home.regionalSection.paragraph2", group: "home", labelDe: "Regional: Absatz 2", labelEn: "Regional: Paragraph 2", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.regionalSection.paragraph2 },
{ key: "home.sustainability.items", group: "home", labelDe: "Nachhaltigkeit: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Sustainability: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.sustainability.items) },
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
// --- Homepage-Bilder (nicht lokalisiert — ein Bild gilt fuer beide
// Sprachen; Wert ist der von app/api/admin/upload/route.ts zurueckgegebene
// Pfad, z.B. "/uploads/<zufaellig>.webp". Leer = kein Bild gepflegt, die
// Komponente zeigt dann weiterhin den bisherigen CSS-Platzhalter, siehe
// components/content-image.tsx.) Deckt jede Stelle in hero.tsx/
// home-sections-a.tsx/home-sections-b.tsx ab, die ein festes,
// redaktionelles Bild zeigt — der homepage-Produkt-Spotlight-Abschnitt
// (ProductSpotlight) ist bewusst NICHT hier: er zeigt kein festes Bild,
// sondern das jeweils dynamisch ausgewaehlte Produkt und nutzt dafuer
// dessen eigenes Product.images (siehe admin/produkte).
{ key: "home.hero.image", group: "home", labelDe: "Hero: Bild", labelEn: "Hero: Image", type: "image", localized: false, getDefault: () => "" },
// Optionales Hintergrundvideo fuer den Hero (MP4/WebM, siehe
// app/api/admin/upload-video/route.ts). Wenn gepflegt, zeigt hero.tsx das
// Video statt des Hero-Bilds/Platzhalters an derselben Stelle — home.hero.image
// bleibt als Fallback/Poster-Bild erhalten, falls das Video wieder entfernt wird.
{ key: "home.hero.video", group: "home", labelDe: "Hero: Hintergrundvideo (MP4/WebM, optional, ersetzt das Hero-Bild)", labelEn: "Hero: Background video (MP4/WebM, optional, replaces the hero image)", type: "video", localized: false, getDefault: () => "" },
{ key: "home.originStory.image", group: "home", labelDe: "Herkunft: Bild", labelEn: "Origin: Image", type: "image", localized: false, getDefault: () => "" },
{ key: "home.regionalSection.image", group: "home", labelDe: "Regional: Bild", labelEn: "Regional: Image", type: "image", localized: false, getDefault: () => "" },
{ key: "home.businessTeaser.image", group: "home", labelDe: "Geschäftskunden-Hinweis: Bild", labelEn: "Business teaser: Image", type: "image", localized: false, getDefault: () => "" },
// --- Weitere Seiten (nicht lokalisiert) ---
{ key: "imkerei.image", group: "pages", labelDe: "Imkerei-Seite: Bild", labelEn: "Imkerei page: Image", type: "image", localized: false, getDefault: () => "" },
{ key: "herkunft.image", group: "pages", labelDe: "Herkunft-Seite: Bild", labelEn: "Herkunft page: Image", type: "image", localized: false, getDefault: () => "" },
{ key: "unternehmen.image", group: "pages", labelDe: "Geschäftskunden-Seite: Bild", labelEn: "Business page: Image", type: "image", localized: false, getDefault: () => "" },
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
// angezeigt, siehe components/site-footer.tsx Footer()) ---
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
{ key: "social.youtube", group: "social", labelDe: "YouTube-Kanal (URL)", labelEn: "YouTube channel (URL)", type: "text", localized: false, getDefault: () => "" },
{ key: "social.instagram", group: "social", labelDe: "Instagram-Profil (URL)", labelEn: "Instagram profile (URL)", type: "text", localized: false, getDefault: () => "" },
// --- Rechtstexte (lokalisiert) ---
{ key: "legal.impressum", group: "legal", labelDe: "Impressum", labelEn: "Legal notice", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).impressumPage.sections) },
{ key: "legal.datenschutz", group: "legal", labelDe: "Datenschutz", labelEn: "Privacy policy", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).datenschutzPage.sections) },
{ key: "legal.agb", group: "legal", labelDe: "AGB", labelEn: "Terms and conditions", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).agbPage.sections) },
{ key: "legal.widerruf", group: "legal", labelDe: "Widerrufsbelehrung", labelEn: "Right of withdrawal", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).widerrufPage.sections) },
{ key: "legal.cookieRichtlinie", group: "legal", labelDe: "Cookie-Richtlinie", labelEn: "Cookie policy", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).cookieRichtliniePage.sections) },
];
export function getContentFieldDef(key: string): ContentFieldDef | undefined {
return CONTENT_REGISTRY.find((f) => f.key === key);
}