Abosystem mit dauerhaften Rabatten und Teddy-Prämien

Drei Mitgliedschaftsstufen (Klein 4,99€/5%, Mittel 9,99€/10%, Groß 19,99€/20%)
mit automatischem Bestellrabatt und Teddy-Prämien nach Meilensteinen (12-Monats-
Rhythmus bzw. 6+12 bei Groß). Preise/Rabatte und alle Admin-Regeln (Kombinier-
barkeit mit anderen Rabatten, Fortsetzung bei Wiederanmeldung, Monate über
Stufen zusammenzählen, kostenloser Versand bei Prämie) sind über Decap CMS
einstellbar (src/content/abo-einstellungen.json).

Neuer "Mein Abo"-Bereich im Kundenkonto (6. Nav-Kachel) mit Plan-Vergleich,
Live-Status (Laufzeit, nächste Zahlung, Rabatt, Fortschritt bis zur nächsten
Prämie), Einlösen-Buttons für freigeschaltete Teddys, Kündigen/Wechseln sowie
einer übersetzten Benachrichtigungsliste. Dazu ein extra hervorgehobener
"Mein Abo verwalten"-Button neben Abmelden in der Kopfkarte.

Warenkorb/Checkout rechnen den Abo-Rabatt korrekt in die Summe ein und stellen
eingelöste Teddys als 0€-Gratisposition dar. Alles in 4 Sprachen (DE/EN/CH/FR).

Unterwegs zwei echte Bugs gefunden und behoben: addMonateISO() vermischte
lokale Zeit mit toISOString() (UTC) und verlor dadurch bei jeder Monats-
verlängerung reproduzierbar einen Tag; die Checkout-Positionsliste zeigte für
Gratis-Prämien fälschlich den vollen Preis statt 0€.

Wie beim Treuebonus gilt: Speicherung aktuell im localStorage des Geräts
(Phase 1) statt einer echten geräteübergreifenden Datenbank — folgt mit der
Zahlungsanbindung in Phase 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 16:54:25 +02:00
co-authored by Claude Sonnet 5
parent 0cb7d3ca65
commit 208d995941
21 changed files with 1902 additions and 91 deletions
+85
View File
@@ -0,0 +1,85 @@
// Abo-System: Preise/Rabatte/Verhaltensregeln kommen aus dem Adminbereich (Decap CMS, siehe
// public/admin/config.yml → "🎁 Abo-System" → src/content/abo-einstellungen.json). Reine,
// zustandslose Berechnungsfunktionen — der eigentliche Konto-Zustand (welches Abo, wie viele
// bezahlte Monate, freigeschaltete Prämien) lebt in scripts/account.ts.
// @ts-ignore -- JSON-Import, zur Build-Zeit von Astro/Vite aufgelöst
import raw from "../content/abo-einstellungen.json";
export type AboStufe = "klein" | "mittel" | "gross";
export interface AboStufenKonfig {
preis: number;
rabattProzent: number;
}
export interface AboEinstellungen {
klein: AboStufenKonfig;
mittel: AboStufenKonfig;
gross: AboStufenKonfig;
kombinierbarMitAnderenRabatten: boolean;
versandKostenlosBeiPraemie: boolean;
fortsetzungBeiWiederanmeldung: boolean;
monateUeberStufenZusammenzaehlen: boolean;
}
export const aboEinstellungen = raw as AboEinstellungen;
export function aboStufenKonfig(stufe: AboStufe): AboStufenKonfig {
return aboEinstellungen[stufe];
}
export const ABO_STUFEN: AboStufe[] = ["klein", "mittel", "gross"];
/** Wie viele Teddy-Prämien welcher Art bei X durchgehend bezahlten Monaten in dieser Stufe
* INSGESAMT freigeschaltet sein müssten (kumulativ, nicht nur "neu diesen Monat"):
* - Klein-Abo: 1 kleiner Teddy pro volles Jahr.
* - Mittel-Abo: 1 großer Teddy pro volles Jahr.
* - Groß-Abo: 1 kleiner Teddy alle 6 Monate UND zusätzlich 1 großer Teddy pro volles Jahr
* (nach 6 Monaten: 1 klein · nach 12: 2 klein + 1 groß · nach 18: 3 klein · nach 24: 4 klein + 2 groß). */
export function teddyMeilensteine(stufe: AboStufe, monate: number): { klein: number; gross: number } {
if (monate <= 0) return { klein: 0, gross: 0 };
if (stufe === "klein") return { klein: Math.floor(monate / 12), gross: 0 };
if (stufe === "mittel") return { klein: 0, gross: Math.floor(monate / 12) };
return { klein: Math.floor(monate / 6), gross: Math.floor(monate / 12) };
}
/** Wie viele Monate fehlen noch bis zum jeweils nächsten Meilenstein (für die
* Fortschrittsanzeige im Kundenkonto), null wenn diese Stufe die Prämienart gar nicht kennt. */
export function naechsteMeilensteine(stufe: AboStufe, monate: number): { klein: number | null; gross: number | null } {
const kleinIntervall = stufe === "gross" ? 6 : stufe === "klein" ? 12 : null;
const grossIntervall = stufe === "klein" ? null : 12;
return {
klein: kleinIntervall ? kleinIntervall - (monate % kleinIntervall) : null,
gross: grossIntervall ? grossIntervall - (monate % grossIntervall) : null,
};
}
/** Ein Abo ist aktiv, solange der aktuell bezahlte Abrechnungszeitraum noch nicht abgelaufen
* ist — reiner Datumsvergleich zur echten, aktuellen Uhrzeit (läuft also automatisch ab, ganz
* ohne Cronjob/Backend), genau wie befristete Produkt-Rabatte (siehe data/rabatt.ts). Gilt
* bewusst UNABHÄNGIG davon, ob das Abo schon gekündigt wurde — gekündigte Abos behalten ihre
* Vorteile bis zum Ende der bezahlten Laufzeit. */
export function aboAktuellAktiv(bezahltBisDatum: string | null): boolean {
if (!bezahltBisDatum) return false;
return new Date() <= new Date(`${bezahltBisDatum}T23:59:59`);
}
/** Heutiges Datum als lokales Kalenderdatum (NICHT über toISOString(), das auf UTC umrechnet und
* in Zeitzonen östlich von UTC nachts kurzzeitig das falsche — gestrige — Datum liefern würde). */
export function heutigesDatumISO(): string {
const heute = new Date();
const jahr = heute.getFullYear();
const monat = String(heute.getMonth() + 1).padStart(2, "0");
const tag = String(heute.getDate()).padStart(2, "0");
return `${jahr}-${monat}-${tag}`;
}
/** Rechnet rein kalendarisch in UTC (bewusst NICHT über lokale Zeit + toISOString() gemischt —
* das würde je nach Zeitzone des Geräts bei jedem Aufruf einen Tag "verlieren" und sich über
* viele Monate hinweg zu einem spürbaren Datumsfehler aufsummieren; mit reinem UTC-Rechnen ist
* das Ergebnis unabhängig von der Zeitzone des Kunden immer exakt). */
export function addMonateISO(datumISO: string, monate: number): string {
const [jahr, monat, tag] = datumISO.split("-").map(Number);
return new Date(Date.UTC(jahr, monat - 1 + monate, tag)).toISOString().slice(0, 10);
}