Aktion mit Preisangabe nach § 11 PAngV
Aktion mit echtem Zeitraum statt Countdown: Aktionspreis, niedrigster Preis der 30 Tage davor als Streichpreis, Prozent von diesem Preis, aufklappbarer Preisverlauf und Status aus der Uhr; dazu Stufen und Abendpreis für Ware vom Tag.
Laden, D2C, Mode, Optik, Fahrrad: Aktion mit Start und Ende, je Artikel 30-Tage-Preis und Preisverlauf.
Eine Aktion mit echtem Zeitraum statt Schein-Countdown. Jede Ermäßigung nennt den niedrigsten Gesamtpreis der letzten 30 Tage vor der Aktion, und der Prozentwert rechnet von diesem Preis aus, nicht von einem alten oder empfohlenen Preis. Ein aufklappbarer Preisverlauf zeigt die 30 Tage als Linie mit dem Bezugspreis. Der Status kommt aus der echten Uhr: beginnt am, läuft bis, letzter Tag, beendet. Drei Fälle: Aktion mit Zeitraum, schrittweise Senkung (Bezug bleibt der Preis vor der ersten Stufe) und Abverkauf kurz haltbarer Ware am Abend (Ausnahme mit klarem Grund).
Rabatte ziehen, aber Kundschaft und Verbraucherschützer schauen genau hin. Seit dem EuGH-Urteil vom 26.09.2024 (C-330/23) muss eine prozentuale Ermäßigung vom niedrigsten Preis der letzten 30 Tage ausgehen; Abmahnungen sind teuer. Eine Aktion, die ihren Rechenweg offen zeigt und ein echtes Ende hat, wirkt glaubwürdig und schützt den Betrieb. Ein ruhiger Status ersetzt die tickende Uhr und passt zu Läden, die ohne Verkaufsdruck auftreten.
Wann einsetzen
Laden vor Ort mit Online-Sortiment, D2C-Shop, Mode, Optik, Fahrrad, Bäckerei und Hofladen (Abverkauf am Abend), saisonale Aktionen, Schlussverkauf, Jubiläum.
Wann nicht
Bei preisgebundenen Büchern (Buchpreisbindung) und überall, wo keine echte Preishistorie vorliegt; dann lieber kein Streichpreis. Nicht für individuelle Rabatte (Kundenkarte, Gutschein), dort gilt die 30-Tage-Pflicht laut § 11 Abs. 4 nicht. Keinen Countdown, der neu startet oder kein echtes Ende hat. Rechtliches vom Kunden prüfen lassen, keine Rechtsberatung.
Varianten
- Zeitraum: Aktion mit Start und Ende, Status aus der Uhr, Prozent vom 30-Tage-Tiefstpreis, Preisverlauf
- Stufen: schrittweise Senkung ohne Unterbrechung, Bezug bleibt der niedrigste Preis vor der ersten Stufe
- Abverkauf: kurz haltbare Ware ab einer Uhrzeit günstiger, mit Grund (drohender Verderb) statt 30-Tage-Preis
Umsetzung
Daten je Artikel: Normalpreis, Aktionspreis, Preisliste der letzten 30 Tage vor Aktionsbeginn (aus dem Warenwirtschafts- oder Shopsystem, nie von Hand geschätzt). Bezugspreis = Minimum dieser Liste; Prozent = 1 − Aktionspreis / Bezugspreis, abgerundet auf ganze Zahlen. Angezeigt werden Aktionspreis (groß), „Niedrigster Preis der letzten 30 Tage: 44,90 €“ als Streichpreis in derselben Zeile und der Prozentwert. Liegt der Bezugspreis unter dem Normalpreis, nennt die Demo das offen. Preisverlauf in <details> als SVG-Linie mit markiertem Tiefstpunkt und Text-Tabelle für Screenreader. Zeitraum als <time datetime> von–bis; Status aus new Date() gegen Start und Ende in Europe/Berlin, aktualisiert minütlich, ohne Sekundenticker. Nach dem Ende verschwindet der Streichpreis und der Normalpreis gilt. Stufen: Bezug bleibt der niedrigste Preis vor der ersten Stufe (§ 11 Abs. 2), die Stufen stehen als Zeitleiste da. Abverkauf: Ausnahme nach § 11 Abs. 4 Nr. 2 nur mit sichtbarem Grund („Brot vom Tag, wegen kurzer Haltbarkeit“), Uhrzeit aus der echten Uhr. Optional Offer-JSON-LD mit priceValidUntil. Rechtliches vom Kunden prüfen lassen.
Robust umsetzen
Telefon. Artikel einspaltig, Preisblock rechtsbündig unter dem Namen, Preisverlauf in voller Breite. Das Aktionsband steht oben mit Zeitraum und Status in zwei Zeilen, ohne feste Leiste.
Ohne Bewegung. Preisverlauf öffnet ohne Gleiten, die Linie steht sofort. Statuswechsel ohne Übergang.
Barrierefrei. Streichpreis nicht nur durchgestrichen: davor steht als Text „Niedrigster Preis der letzten 30 Tage“, die Durchstreichung ist zusätzlich. Prozentwert als Text, nicht nur als Badge-Farbe. Preisverlauf mit Tabelle als Textalternative. Status als Satz mit Datum („Läuft bis Sonntag, 16. November, 18 Uhr“), kein Live-Ticker. Kontrast der Aktionsfarbe als Schrift mindestens 4,5 : 1.
Code
<!DOCTYPE html>
<!--
Aktion mit Preisangabe nach § 11 PAngV
Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/aktion-mit-preisangabe/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de" data-v="zeitraum">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aktion mit Preisangabe nach § 11 PAngV · 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: 'Libre Caslon Display'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/libre-caslon-display@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Familjen Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/familjen-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Shrikhand'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/shrikhand@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Onest'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/onest:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; 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; }
/* Zeitraum und Stufen: Rezept laden-vor-ort-sortiment (Papier, Kraftkarton, Tinte, Schaufenstergrün) */
:root {
--grund: #F5F1E8; --text: #211F1C; --flaeche: #E2D3B8; --dunkel: #2C2A26; --signal: #2E5B45; --leise: #615B51; --linie: #D9CFBE; --linie-stark: #8f877a;
--kopf: 'Libre Caslon Display', Georgia, serif; --schrift: 'Familjen Grotesk', system-ui, sans-serif; --kopf-gross: clamp(2.5rem, 1.5rem + 4vw, 5rem); --kopf-lh: 1;
--r: 2px; --r-gross: 6px;
--ein: cubic-bezier(0.16, 1, 0.3, 1); --aus: cubic-bezier(0.7, 0, 0.84, 0);
--t-ui: 150ms; --t-auf: 300ms; --t-zu: 200ms; --t-linie: 1000ms;
}
/* Abverkauf: Rezept lebensmittelhandwerk-theke */
:root[data-v="abverkauf"] {
--grund: #F7F1E6; --text: #241B14; --flaeche: #EBD8BB; --dunkel: #2B211B; --signal: #8A3C12; --leise: #665746; --linie: #DDCDB4; --linie-stark: #8c7a66;
--kopf: 'Shrikhand', Georgia, serif; --schrift: 'Onest', system-ui, sans-serif; --kopf-gross: clamp(2.25rem, 1.3rem + 3vw, 3.75rem); --kopf-lh: 1.05;
--r: 6px; --r-gross: 16px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: var(--schrift); -webkit-text-size-adjust: 100%; }
body { min-height: 100svh; font-size: 1.0625rem; line-height: 1.6; }
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
:root:has(:focus-visible) { scroll-behavior: auto; }
a { color: inherit; text-underline-offset: .2em; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.z { font-variant-numeric: tabular-nums lining-nums; }
.kopf { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 6px 24px; padding: 18px clamp(16px, 4vw, 56px); border-bottom: 1px solid var(--linie); }
.marke { font-family: var(--kopf); font-size: 1.625rem; line-height: 1; text-decoration: none; letter-spacing: -.01em; }
.kopf small { font-size: .9375rem; color: var(--leise); }
main { max-width: 1280px; margin: 0 auto; padding: clamp(28px, 4vw, 56px) clamp(16px, 4vw, 56px) clamp(72px, 8vw, 112px); }
/* Aktionsband */
.band { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 24px clamp(24px, 5vw, 72px); align-items: end; padding-bottom: clamp(28px, 4vw, 44px); border-bottom: 1px solid var(--text); }
.rubrik { font-size: .9375rem; font-weight: 600; color: var(--leise); margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.rubrik .marke-aktion { display: inline-block; padding: 3px 8px 2px; background: var(--signal); color: #fff; font-size: .8125rem; letter-spacing: .02em; border-radius: var(--r); }
h1 { font-family: var(--kopf); font-weight: 400; font-size: var(--kopf-gross); line-height: var(--kopf-lh); letter-spacing: -.015em; text-wrap: balance; }
:root[data-v="abverkauf"] h1 { letter-spacing: -.01em; }
.band .unter { margin-top: 16px; max-width: 46ch; text-wrap: pretty; }
.status { background: var(--flaeche); border-radius: var(--r-gross); padding: 18px 20px 20px; }
.status .kurz { font-size: .875rem; font-weight: 600; color: var(--leise); display: flex; align-items: center; gap: 8px; }
.status .kurz::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--signal); }
.status[data-phase="vorher"] .kurz::before, .status[data-phase="vorbei"] .kurz::before { background: transparent; box-shadow: inset 0 0 0 1.5px var(--leise); }
.status .satz { font-size: 1.25rem; font-weight: 600; line-height: 1.3; margin-top: 6px; text-wrap: balance; }
.status .zeitraum { margin-top: 6px; font-size: .9375rem; color: var(--leise); }
.tage { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; margin-top: 14px; }
.tage i { height: 6px; background: color-mix(in srgb, var(--text) 16%, transparent); border-radius: 1px; }
.tage i.vorbei { background: var(--text); }
.tage i.heute { background: var(--signal); }
.tage-legende { display: flex; justify-content: space-between; font-size: .75rem; color: var(--leise); margin-top: 6px; }
/* Stufen */
.stufen { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin-top: 14px; }
.stufen li { padding: 10px 10px 8px; border-radius: var(--r); background: color-mix(in srgb, var(--grund) 60%, transparent); font-size: .8125rem; line-height: 1.35; color: var(--leise); }
.stufen li b { display: block; font-size: 1.25rem; color: var(--text); font-variant-numeric: tabular-nums; }
.stufen li.jetzt { background: var(--signal); color: #fff; }
.stufen li.jetzt b { color: #fff; }
/* Artikel */
.artikel { list-style: none; }
.artikel > li { display: grid; grid-template-columns: 112px minmax(0, 1fr) minmax(220px, auto); gap: 8px clamp(16px, 3vw, 40px); padding: 26px 0; border-bottom: 1px solid var(--linie); align-items: start; }
.bild { width: 112px; height: 112px; background: var(--flaeche); border-radius: var(--r-gross); display: grid; place-items: center; }
.bild svg { width: 76px; height: 76px; }
.was h2 { font-family: var(--kopf); font-weight: 400; font-size: 1.75rem; line-height: 1.1; }
:root[data-v="abverkauf"] .was h2 { font-family: var(--schrift); font-weight: 650; font-size: 1.25rem; }
.was p { color: var(--leise); font-size: .9375rem; margin-top: 4px; max-width: 48ch; }
.was .grund { color: var(--text); font-weight: 500; margin-top: 8px; display: flex; gap: 8px; align-items: baseline; }
.preisblock { text-align: right; display: grid; justify-items: end; gap: 2px; }
.preis { font-size: 1.75rem; font-weight: 650; line-height: 1.1; font-variant-numeric: tabular-nums lining-nums; }
.preis.aktion { color: var(--text); }
.minus { display: inline-block; padding: 2px 7px; border-radius: var(--r); background: var(--signal); color: #fff; font-size: .9375rem; font-weight: 650; font-variant-numeric: tabular-nums; margin-right: 10px; vertical-align: .22em; }
.bezug { font-size: .875rem; color: var(--leise); line-height: 1.4; max-width: 44ch; }
.bezug s { color: var(--text); text-decoration-thickness: 1px; }
.normal-hinweis { font-size: .875rem; color: var(--leise); }
.hinweis-bezug { margin-top: 6px; font-size: .8125rem; line-height: 1.45; color: var(--text); background: color-mix(in srgb, var(--flaeche) 70%, transparent); padding: 8px 10px; border-radius: var(--r); max-width: 34ch; text-align: left; }
.verlauf-knopf { margin-top: 8px; border: 0; background: none; padding: 6px 0; font-size: .875rem; font-weight: 600; text-decoration: underline; text-underline-offset: .2em; text-decoration-thickness: 1px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.verlauf-knopf svg { width: 10px; height: 10px; transition: rotate var(--t-zu) var(--aus); }
.verlauf-knopf[aria-expanded="true"] svg { rotate: 180deg; transition: rotate var(--t-auf) var(--ein); }
.verlauf { grid-column: 2 / -1; display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--t-zu) var(--aus); }
.verlauf.offen { grid-template-rows: 1fr; transition: grid-template-rows var(--t-auf) var(--ein); }
.verlauf > div { overflow: hidden; min-height: 0; }
.verlauf-innen { padding: 12px 0 4px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 18rem); gap: 12px 28px; align-items: center; }
.verlauf svg { width: 100%; height: auto; display: block; overflow: visible; }
.verlauf .linie { fill: none; stroke: var(--text); stroke-width: 1.6; stroke-linejoin: round; }
.verlauf .flaeche-v { fill: color-mix(in srgb, var(--flaeche) 80%, transparent); }
.verlauf .tief { fill: var(--signal); }
.verlauf .achse { font: 500 11px/1 var(--schrift); fill: var(--leise); }
.verlauf .tief-text { font: 650 12px/1 var(--schrift); fill: var(--text); }
.verlauf .zusammen { font-size: .875rem; line-height: 1.5; }
.verlauf .zusammen b { font-weight: 650; }
.fuss { margin-top: 28px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px 48px; font-size: .875rem; color: var(--leise); }
.fuss h3 { font-size: .9375rem; color: var(--text); margin-bottom: 6px; }
.fuss ul { padding-left: 1.1em; }
.fuss p, .fuss li { max-width: 62ch; }
/* Pult */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 40; display: grid; gap: 4px; padding: 5px; background: rgba(33, 31, 28, .93); color: #f5f1e8; border-radius: 18px; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; max-width: calc(100vw - 32px); }
.pult div { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
.pult span { padding: 0 8px 0 10px; opacity: .75; min-width: 8.5em; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; background: none; border: 0; border-radius: 999px; padding: 9px 11px; cursor: pointer; }
.pult button[aria-pressed="true"] { background: #f5f1e8; color: #211f1c; }
.pult button:focus-visible { outline: 2px solid #b7d6c4; outline-offset: 1px; }
@media (max-width: 860px) {
.band { grid-template-columns: minmax(0, 1fr); }
.artikel > li { grid-template-columns: 72px minmax(0, 1fr); }
.bild { width: 72px; height: 72px; } .bild svg { width: 52px; height: 52px; }
.preisblock { grid-column: 2; text-align: left; justify-items: start; }
.hinweis-bezug { max-width: none; }
.verlauf { grid-column: 1 / -1; }
.verlauf-innen { grid-template-columns: minmax(0, 1fr); }
.fuss { grid-template-columns: minmax(0, 1fr); }
.was h2 { font-size: 1.5rem; }
}
@media (max-width: 360px) { .artikel > li { grid-template-columns: minmax(0, 1fr); } .preisblock { grid-column: 1; } .stufen { grid-template-columns: minmax(0, 1fr); } }
.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .fuss { display: none; }
:root.reduziert *, :root.reduziert *::before, :root.reduziert *::after { 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; }
.minus, .rubrik .marke-aktion { border: 1px solid CanvasText; }
.tage i.vorbei, .tage i.heute, .stufen li.jetzt { background: Highlight; }
}
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#" data-marke>Kettenblatt</a>
<small data-ort>Radladen und Werkstatt · Eschwege, Marktplatz</small>
</header>
<main>
<section class="band" aria-labelledby="aktion-titel">
<div>
<p class="rubrik"><span class="marke-aktion" data-aktion-wort>Aktion</span><span data-rubrik></span></p>
<h1 id="aktion-titel" data-titel></h1>
<p class="unter" data-unter></p>
</div>
<div class="status" data-status role="group" aria-label="Zeitraum der Aktion">
<p class="kurz" data-kurz></p>
<p class="satz" data-satz></p>
<p class="zeitraum" data-zeitraum></p>
<div data-zeitbild></div>
</div>
</section>
<ul class="artikel" data-artikel></ul>
<div class="fuss">
<div>
<h3 data-fuss-titel>So rechnen wir</h3>
<p data-fuss-text></p>
</div>
<div>
<h3>Preise</h3>
<p data-fuss-preise></p>
</div>
</div>
</main>
<p class="vh" role="status" aria-live="polite" data-ansage></p>
<div class="pult" role="group" aria-label="Steuerung der Demo">
<div><span>Variante</span><button type="button" data-wahl="zeitraum" aria-pressed="true">Zeitraum</button><button type="button" data-wahl="stufen" aria-pressed="false">Stufen</button><button type="button" data-wahl="abverkauf" aria-pressed="false">Abverkauf</button></div>
<div data-zeiten><span>Zeitpunkt</span></div>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
const TZ = 'Europe/Berlin';
const tagLang = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long', timeZone: TZ });
const tagKurz = new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'numeric', timeZone: TZ });
const tagMonat = new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', timeZone: TZ });
const uhr = new Intl.DateTimeFormat('de-DE', { hour: 'numeric', minute: '2-digit', timeZone: TZ });
const TAG = 864e5;
const mitternacht = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; };
const plus = (d, n) => new Date(+d + n * TAG);
// Demo-Daten relativ zu heute, damit die Aktion beim Ansehen läuft. Echte Seiten: feste Daten aus dem System.
const HEUTE = mitternacht(new Date());
const START = plus(HEUTE, -2), ENDE = new Date(+plus(HEUTE, 11) + 18 * 36e5);
const ICON = {
front: '<path d="M14 38h14l10-10h12v20H38L28 38" /><path d="M50 28c6 0 10 4 10 10s-4 10-10 10"/><path d="M62 34l8-3M62 38h9M62 42l8 3"/><path d="M14 38v8"/>',
rueck: '<rect x="26" y="16" width="24" height="44" rx="10"/><path d="M32 26h12M32 34h12M32 42h12"/><path d="M38 60v8"/>',
weste: '<path d="M26 14l-10 8v42h18V40h8v24h18V22l-10-8-8 10h-8Z"/><path d="M16 44h18M42 44h18M16 52h18M42 52h18"/>',
set: '<rect x="10" y="30" width="26" height="16" rx="5"/><rect x="44" y="26" width="18" height="26" rx="7"/><path d="M36 38h8"/><path d="M14 38h4"/>',
jacke: '<path d="M28 12h20l14 10-6 12-6-3v35H26V31l-6 3-6-12Z"/><path d="M38 14v52M31 40h4M41 40h4"/>',
hose: '<path d="M24 12h28l4 54H42l-4-34-4 34H20Z"/><path d="M24 20h28"/>',
handschuh: '<path d="M26 64V38l-6-10c-2-4 3-7 6-3l4 7V16c0-4 6-4 6 0v14-18c0-4 6-4 6 0v18-14c0-4 6-4 6 0v16-10c0-4 6-4 6 0v26c0 10-6 14-12 18v4Z"/>',
laib: '<path d="M10 46c0-14 12-24 28-24s28 10 28 24c0 7-6 11-28 11S10 53 10 46Z"/><path d="M24 32l6 12M36 28l5 14M48 30l4 12"/>',
brezel: '<path d="M38 54c-14 0-24-8-24-20 0-8 6-14 13-14 9 0 11 10 11 22 0-12 2-22 11-22 7 0 13 6 13 14 0 12-10 20-24 20Z"/><path d="M24 50l28-22M52 50 24 28"/>',
kuchen: '<path d="M12 44h52v14H12z"/><path d="M12 44l26-20 26 20"/><circle cx="26" cy="51" r="1.6"/><circle cx="38" cy="51" r="1.6"/><circle cx="50" cy="51" r="1.6"/>'
};
const icon = k => `<svg viewBox="0 0 76 76" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${ICON[k]}</svg>`;
// Preisverlauf der 30 Tage vor Aktionsbeginn: [Tage vor Start (30 bis 1), Preis]
const verlauf = (normal, ausnahmen = []) => Array.from({ length: 30 }, (_, i) => { const tag = 30 - i; const a = ausnahmen.find(x => tag <= x[0] && tag >= x[1]); return a ? a[2] : normal; });
const DATEN = {
zeitraum: {
marke: 'Kettenblatt', ort: 'Radladen und Werkstatt · Eschwege, Marktplatz', rubrik: 'Lichtwochen',
titel: 'Gut gesehen durch den November', unter: 'Beleuchtung und Reflexkleidung sind reduziert, solange die Lichtwochen laufen. Alles im Laden vorrätig, Montage in der Werkstatt am selben Tag.',
artikel: [
{ name: 'Frontlicht Lumen 70', text: 'StVZO-zugelassen, 70 Lux, USB-C, Halter für Lenker und Gabel.', icon: 'front', normal: 49, aktion: 39.2, preise: verlauf(49) },
{ name: 'Rücklicht Nachtfalke', text: 'Mit Bremslichtfunktion, 12 Stunden Laufzeit.', icon: 'rueck', normal: 24.9, aktion: 19.9, preise: verlauf(24.9, [[16, 12, 21.9]]) },
{ name: 'Reflexweste Sichtbar', text: 'Atmungsaktiv, mit Reflexstreifen rundum, Größen S bis XXL.', icon: 'weste', normal: 29, aktion: 23.2, preise: verlauf(29) },
{ name: 'Akku-Lichtset Zwielicht', text: 'Front und Rücklicht als Set, gemeinsam laden.', icon: 'set', normal: 89, aktion: 71.2, preise: verlauf(89) }
],
fussTitel: 'So rechnen wir',
fussText: 'Neben jedem Aktionspreis steht der niedrigste Preis, den das Teil in den 30 Tagen vor den Lichtwochen bei uns gekostet hat. Davon rechnen wir den Prozentwert. War ein Teil vorher schon einmal günstiger, fällt die Ersparnis entsprechend kleiner aus.',
fussPreise: 'Alle Preise sind Endpreise inkl. MwSt. Abholung im Laden ohne Aufpreis, Versand zzgl. 4,90 €. Nach dem Ende der Aktion gelten wieder die Normalpreise.'
},
stufen: {
marke: 'Kettenblatt', ort: 'Radladen und Werkstatt · Eschwege, Marktplatz', rubrik: 'Saisonwechsel Radbekleidung',
titel: 'Sommerware geht, Woche für Woche günstiger', unter: 'Wir räumen die Bekleidung der Saison. Der Preis sinkt jede Woche einmal, ohne Pause, bis die Ware weg ist.',
stufen: [[0, .2], [7, .3], [14, .4]],
artikel: [
{ name: 'Regenjacke Werra', text: 'Wasserdicht, Packmaß einer Faust, Reflexpaspeln.', icon: 'jacke', normal: 149, preise: verlauf(149) },
{ name: 'Radhose Meißner', text: 'Dreiviertellang, mit Sitzpolster, Damen und Herren.', icon: 'hose', normal: 79, preise: verlauf(79, [[24, 22, 69]]) },
{ name: 'Handschuhe Kurzfinger', text: 'Gelpolster, Frottee am Daumen.', icon: 'handschuh', normal: 24.9, preise: verlauf(24.9) }
],
fussTitel: 'So rechnen wir bei Stufen',
fussText: 'Weil der Preis ohne Unterbrechung Schritt für Schritt sinkt, bleibt der Bezug die ganze Zeit derselbe: der niedrigste Preis in den 30 Tagen vor der ersten Stufe. Alle Prozentwerte rechnen von diesem Preis.',
fussPreise: 'Alle Preise sind Endpreise inkl. MwSt. Solange der Vorrat reicht; ausverkaufte Größen nehmen wir sofort heraus.'
},
abverkauf: {
marke: 'Morgenrot', ort: 'Bäckerei · Eschwege, Brühl', rubrik: 'Brot vom Tag',
titel: 'Ab 17 Uhr ein Drittel günstiger', unter: 'Was abends übrig ist, wird morgen nicht mehr frisch. Deshalb geben wir Brot und Gebäck vom Tag ab 17 Uhr bis Ladenschluss günstiger ab.',
ab: 17, bis: 18.5, rabatt: 1 / 3,
artikel: [
{ name: 'Bauernlaib, 1000 g', text: 'Roggenmischbrot mit Natursauerteig.', icon: 'laib', normal: 4.8 },
{ name: 'Laugenbrezel', text: 'Von heute früh, mit grobem Salz.', icon: 'brezel', normal: 1.2 },
{ name: 'Streuselkuchen, Stück', text: 'Vom Blech, mit Butterstreuseln.', icon: 'kuchen', normal: 2.4 }
],
fussTitel: 'Warum ohne 30-Tage-Preis',
fussText: 'Der Abendpreis gilt nur für Ware vom Tag, weil sie bald nicht mehr frisch ist. Für diesen Fall verlangt die Preisangabenverordnung keinen 30-Tage-Preis, der Grund muss aber dabeistehen.',
fussPreise: 'Alle Preise sind Endpreise inkl. MwSt. Solange der Vorrat reicht, nur im Laden, keine Vorbestellung zum Abendpreis.'
}
};
let v = 'zeitraum', zeit = 'jetzt', D = DATEN.zeitraum;
const ZEITEN = {
zeitraum: [['jetzt', 'Heute'], ['vorher', 'Vor Beginn'], ['letzter', 'Letzter Tag'], ['vorbei', 'Beendet']],
stufen: [['s1', 'Stufe 1'], ['s2', 'Stufe 2'], ['s3', 'Stufe 3'], ['vorbei', 'Beendet']],
abverkauf: [['jetzt', 'Echte Uhr'], ['nachmittag', '15:00'], ['abend', '17:30'], ['spaet', '19:00']]
};
// Gedachtes „Jetzt“ für die Zustände; „jetzt“ ist die echte Uhr
function jetztFuer() {
const n = new Date();
if (v === 'zeitraum') return { jetzt: n, vorher: plus(START, -3), letzter: new Date(+mitternacht(ENDE) + 10 * 36e5), vorbei: plus(ENDE, 1) }[zeit] || n;
if (v === 'stufen') { const s0 = START; return { s1: plus(s0, 2), s2: plus(s0, 9), s3: plus(s0, 16), vorbei: plus(s0, 22) }[zeit] || plus(s0, 2); }
const h = { nachmittag: 15, abend: 17.5, spaet: 19 }[zeit];
if (h === undefined) return n;
const d = mitternacht(n); return new Date(+d + h * 36e5);
}
const bezugspreis = a => Math.min(...a.preise);
const prozent = (preis, bezug) => Math.floor((1 - preis / bezug) * 100 + 1e-6);
const runde = x => Math.round(x * 100) / 100;
function kalendertage(a, b) { return Math.round((mitternacht(b) - mitternacht(a)) / TAG); }
function status(n) {
if (v === 'zeitraum') {
const tage = kalendertage(START, ENDE) + 1, heuteIdx = kalendertage(START, n);
let phase, kurz, satz;
if (n < START) { phase = 'vorher'; kurz = 'Noch nicht gestartet'; satz = `Beginnt am ${tagLang.format(START)}.`; }
else if (n > ENDE) { phase = 'vorbei'; kurz = 'Beendet'; satz = `Die Lichtwochen sind vorbei. Seit ${tagLang.format(plus(mitternacht(ENDE), 1))} gelten wieder die Normalpreise.`; }
else if (kalendertage(n, ENDE) === 0) { phase = 'laeuft'; kurz = 'Letzter Tag'; satz = `Heute bis ${uhr.format(ENDE)} Uhr.`; }
else { phase = 'laeuft'; const rest = kalendertage(n, ENDE); kurz = 'Läuft'; satz = `Noch ${rest} Tage, bis ${tagLang.format(ENDE)}, ${uhr.format(ENDE)} Uhr.`; }
const bild = `<div class="tage" aria-hidden="true">${Array.from({ length: tage }, (_, i) => `<i class="${i < heuteIdx ? 'vorbei' : i === heuteIdx && phase === 'laeuft' ? 'heute' : ''}"></i>`).join('')}</div><div class="tage-legende" aria-hidden="true"><span>${tagKurz.format(START)}</span><span>${tagKurz.format(ENDE)}</span></div>`;
return { phase, kurz, satz, zeitraum: `<time datetime="${START.toISOString().slice(0, 10)}">${tagMonat.format(START)}</time> bis <time datetime="${ENDE.toISOString()}">${tagMonat.format(ENDE)}</time>, ${uhr.format(ENDE)} Uhr`, bild };
}
if (v === 'stufen') {
const st = D.stufen, ab = st.map(s => plus(START, s[0])), ende = plus(START, 21);
let i = -1; ab.forEach((d, k) => { if (n >= d) i = k; });
const vorbei = n >= ende;
const phase = vorbei ? 'vorbei' : i < 0 ? 'vorher' : 'laeuft';
const kurz = vorbei ? 'Beendet' : `Stufe ${i + 1} von 3`;
const satz = vorbei ? 'Der Saisonwechsel ist vorbei, die Restposten sind weg.' : i < 2 ? `Jetzt −${Math.round(st[i][1] * 100)} %. Ab ${tagLang.format(ab[i + 1])}: −${Math.round(st[i + 1][1] * 100)} %.` : `Letzte Stufe: −${Math.round(st[i][1] * 100)} % bis ${tagLang.format(plus(ende, -1))} oder solange der Vorrat reicht.`;
const bild = `<ol class="stufen">${st.map((s, k) => `<li class="${k === i && !vorbei ? 'jetzt' : ''}"${k === i && !vorbei ? ' aria-current="step"' : ''}><b>−${Math.round(s[1] * 100)} %</b>ab ${tagKurz.format(ab[k])}</li>`).join('')}</ol>`;
return { phase, kurz, satz, zeitraum: `${tagMonat.format(START)} bis ${tagMonat.format(plus(ende, -1))}, in drei Stufen`, bild, stufe: vorbei ? -1 : i };
}
const h = n.getHours() + n.getMinutes() / 60, an = h >= D.ab && h < D.bis;
const phase = an ? 'laeuft' : 'vorher';
const kurz = an ? 'Jetzt' : h < D.ab ? 'Heute ab 17 Uhr' : 'Morgen wieder ab 17 Uhr';
const satz = an ? 'Abendpreis gilt jetzt, bis Ladenschluss um 18:30 Uhr.' : h < D.ab ? `Ab 17 Uhr gilt der Abendpreis, bis Ladenschluss um 18:30 Uhr.` : 'Der Laden hat geschlossen. Morgen ab 17 Uhr gibt es wieder Brot vom Tag.';
return { phase, kurz, satz, zeitraum: 'Montag bis Samstag, 17:00 bis 18:30 Uhr', bild: '' };
}
function grafik(a, bezug) {
const W = 520, H = 120, pad = 22, p = a.preise, max = Math.max(...p) * 1.04, min = Math.min(...p) * .9;
const x = i => pad + i * (W - 2 * pad) / (p.length - 1), y = w => H - 18 - (w - min) / (max - min) * (H - 40);
let d = `M${x(0)} ${y(p[0])}`; p.forEach((w, i) => { if (i) d += `H${x(i)}V${y(w)}`; });
const ti = p.indexOf(bezug), tj = p.lastIndexOf(bezug);
const fl = d + `V${H - 18}H${x(0)}Z`;
return `<svg viewBox="0 0 ${W} ${H}" aria-hidden="true" focusable="false"><path class="flaeche-v" d="${fl}"/><path class="linie" d="${d}" pathLength="1" style="stroke-dasharray:1;stroke-dashoffset:var(--strich,0)"/>
<circle class="tief" cx="${x((ti + tj) / 2)}" cy="${y(bezug)}" r="4.5"/><text class="tief-text" x="${Math.min(x((ti + tj) / 2), W - 100)}" y="${y(bezug) - 10}" text-anchor="middle">${euro.format(bezug)}</text>
<text class="achse" x="${pad}" y="${H - 2}">${tagKurz.format(plus(START, -30))}</text><text class="achse" x="${W - pad}" y="${H - 2}" text-anchor="end">${tagKurz.format(plus(START, -1))}</text></svg>`;
}
function verlaufSatz(a, bezug) {
const p = a.preise, tief = p.map((w, i) => w === bezug ? i : -1).filter(i => i >= 0);
if (tief.length === p.length) return `In den 30 Tagen vor der Aktion kostete das Teil durchgehend <b>${euro.format(bezug)}</b>. Das ist der Bezugspreis.`;
const von = plus(START, -30 + tief[0]), bis = plus(START, -30 + tief[tief.length - 1]);
return `Vom ${tagMonat.format(von)} bis ${tagMonat.format(bis)} kostete das Teil <b>${euro.format(bezug)}</b>, sonst ${euro.format(a.normal)}. Der niedrigere Preis ist der Bezug.`;
}
function bauen(anim = false) {
D = DATEN[v];
const n = jetztFuer(), s = status(n);
$('[data-marke]').textContent = D.marke; $('[data-ort]').textContent = D.ort;
$('[data-rubrik]').textContent = D.rubrik; $('[data-titel]').textContent = D.titel; $('[data-unter]').textContent = D.unter;
$('[data-aktion-wort]').textContent = v === 'abverkauf' ? 'Abendpreis' : 'Aktion';
const st = $('[data-status]'); st.dataset.phase = s.phase;
$('[data-kurz]').textContent = s.kurz; $('[data-satz]').textContent = s.satz; $('[data-zeitraum]').innerHTML = s.zeitraum; $('[data-zeitbild]').innerHTML = s.bild;
$('[data-fuss-titel]').textContent = D.fussTitel; $('[data-fuss-text]').textContent = D.fussText; $('[data-fuss-preise]').textContent = D.fussPreise;
const laeuft = s.phase === 'laeuft';
$('[data-artikel]').innerHTML = D.artikel.map((a, i) => {
let preis = a.normal, extra = '', block;
if (v === 'abverkauf') {
const abend = runde(a.normal * (1 - D.rabatt));
block = laeuft
? `<p class="preis aktion"><span class="minus">−${prozent(abend, a.normal)} %</span><span class="vh">Abendpreis </span>${euro.format(abend)}</p><p class="bezug">Tagespreis <s>${euro.format(a.normal)}</s></p>`
: `<p class="preis">${euro.format(a.normal)}</p><p class="normal-hinweis">ab 17 Uhr ${euro.format(abend)}</p>`;
extra = `<p class="grund">${laeuft ? 'Brot vom Tag, wegen kurzer Haltbarkeit reduziert.' : 'Ab 17 Uhr als Brot vom Tag günstiger.'}</p>`;
} else {
const bezug = bezugspreis(a);
let aktion = null;
if (v === 'zeitraum' && laeuft) aktion = a.aktion;
if (v === 'stufen' && s.stufe >= 0) aktion = runde(bezug * (1 - D.stufen[s.stufe][1]));
if (aktion !== null) {
const pz = prozent(aktion, bezug);
block = `<p class="preis aktion"><span class="minus">−${pz} %</span><span class="vh">Aktionspreis </span>${euro.format(aktion)}</p>
<p class="bezug">Niedrigster Preis der letzten 30 Tage: <s>${euro.format(bezug)}</s></p>
${bezug < a.normal ? `<p class="hinweis-bezug">Normalpreis ${euro.format(a.normal)}. Vor der Aktion gab es das Teil kurz für ${euro.format(bezug)}, deshalb rechnen wir von diesem Preis: ${pz} %, nicht ${prozent(aktion, a.normal)} %.</p>` : ''}
<button type="button" class="verlauf-knopf" aria-expanded="false" aria-controls="vl-${i}" data-verlauf="${i}">Preisverlauf der 30 Tage<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1 3l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>`;
extra = `<div class="verlauf" id="vl-${i}"><div><div class="verlauf-innen" inert>${grafik(a, bezug)}<p class="zusammen">${verlaufSatz(a, bezug)}</p></div></div></div>`;
} else block = `<p class="preis">${euro.format(a.normal)}</p><p class="normal-hinweis">${s.phase === 'vorher' ? 'Normalpreis, die Aktion beginnt bald' : 'Normalpreis'}</p>`;
}
return `<li><div class="bild">${icon(a.icon)}</div><div class="was"><h2>${a.name}</h2><p>${a.text}</p>${v === 'abverkauf' ? extra : ''}</div><div class="preisblock">${block}</div>${v !== 'abverkauf' ? extra : ''}</li>`;
}).join('');
$$('[data-verlauf]').forEach(k => k.addEventListener('click', () => klappe(k)));
if (anim) $('[data-ansage]').textContent = `${s.kurz}. ${s.satz}`;
}
function klappe(k, auf) {
const ziel = $('#' + k.getAttribute('aria-controls'));
auf = auf ?? k.getAttribute('aria-expanded') !== 'true';
k.setAttribute('aria-expanded', auf); ziel.classList.toggle('offen', auf);
$('.verlauf-innen', ziel).inert = !auf;
const linie = $('.linie', ziel);
if (auf && !reduziert() && linie) linie.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 1000, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
}
function pultZeiten() {
const box = $('[data-zeiten]');
box.innerHTML = '<span>Zeitpunkt</span>' + ZEITEN[v].map(([id, name]) => `<button type="button" data-zeit="${id}" aria-pressed="${id === zeit}">${name}</button>`).join('');
$$('[data-zeit]', box).forEach(b => b.addEventListener('click', () => { pause(); zeit = b.dataset.zeit; $$('[data-zeit]').forEach(x => x.setAttribute('aria-pressed', x === b)); bauen(true); }));
}
function variante(neu, url = true) {
v = DATEN[neu] ? neu : 'zeitraum'; zeit = ZEITEN[v][0][0];
html.dataset.v = v;
$$('[data-wahl]').forEach(b => b.setAttribute('aria-pressed', b.dataset.wahl === v));
pultZeiten(); bauen();
if (url) { const q = new URLSearchParams(location.search); q.set('v', v); history.replaceState(null, '', location.pathname + '?' + q); }
}
$$('[data-wahl]').forEach(b => b.addEventListener('click', () => { const lief = laeuft; pause(); variante(b.dataset.wahl); if (lief) vorfuehren(); }));
// Status minütlich aus der Uhr, kein Sekundenticker
setInterval(() => { if (!laeuft && (zeit === 'jetzt')) bauen(); }, 60000);
// ---------- Vorführung: Zustände nacheinander, Preisverlauf öffnet sich ----------
let laeuft = false, gen = 0, uhrT = null;
const warte = ms => new Promise(r => { uhrT = setTimeout(r, ms); });
function setzeZeit(id) { zeit = id; $$('[data-zeit]').forEach(x => x.setAttribute('aria-pressed', x.dataset.zeit === id)); bauen(); }
async function vorfuehren() {
pause(); laeuft = true; const g = ++gen; html.classList.add('vorfuehrung');
const folge = v === 'zeitraum' ? ['jetzt', 'letzter', 'vorbei', 'vorher'] : v === 'stufen' ? ['s1', 's2', 's3', 'vorbei'] : ['nachmittag', 'abend', 'spaet'];
for (const z of folge) {
setzeZeit(z); await warte(500); if (g !== gen) return;
const k = v === 'zeitraum' ? $('[data-verlauf="1"]') : v === 'stufen' ? $('[data-verlauf="1"]') : null;
if (k && (z === 'jetzt' || z === 's1')) {
const li = k.closest('li'), r = li.getBoundingClientRect(), weich = reduziert() ? 'auto' : 'smooth';
scrollTo({ top: scrollY + r.top - 24, behavior: weich }); await warte(700); if (g !== gen) return;
klappe(k, true); await warte(2600); if (g !== gen) return; klappe(k, false); await warte(300);
scrollTo({ top: 0, behavior: weich }); await warte(600);
}
else { await warte(1500); }
if (g !== gen) return;
}
if (g === gen) uhrT = setTimeout(vorfuehren, 200);
}
function neustart() { pause(); variante(v, false); }
function pause() { gen++; laeuft = false; clearTimeout(uhrT); html.classList.remove('vorfuehrung'); }
function weiter() { vorfuehren(); }
variante(new URLSearchParams(location.search).get('v'), false);
})();
</script>
</body>
</html>