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]>
154 lines
13 KiB
TypeScript
154 lines
13 KiB
TypeScript
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);
|
||
}
|