/* Powermieter — Onboarding flow.
Order: Wohnung → Daten + Zähler → Konditionen + Einwilligungen → SEPA-Mandat
(GoCardless, off-site) → Vertrag prüfen + unterschreiben → Erfolg.
"Erst SEPA, dann Vertrag." Built-in signature (SES); GoCardless best-effort. */
const { useState, useEffect, useRef, useCallback } = React;
const JSON_HEADERS = { 'Content-Type': 'application/json' };
const adresseOf = (b) => `${b.strasse} ${b.hausnr}, ${b.plz} ${b.ort}`;
const TODAY = (() => { try { return new Date().toISOString().slice(0, 10); } catch (e) { return ''; } })();
/* ---------- Screen 1 · Interesse / unit selection ---------------- */
function Screen1({ mode, building, unit, setUnit, onNext }) {
if (mode === 'rep') {
return (
{building.name} · Mieterstrom
Wohnung wählen
Wähle die Wohneinheit des Mieters und reiche das Tablet weiter. Jeder unterschriebene Vertrag aktiviert eine weitere Wohnung an der bereits laufenden Anlage.
{building.units.map((u) => (
{ if (!u.signed) { setUnit(u); onNext(); } }}>
{u.we}
{u.etage} · {u.klingel}
{u.signed ? <> abgeschlossen> : <> offen>}
))}
);
}
const saving = estSaving(unit ? unit.verbrauch : 2500);
return (
{building.name}
Ihr Strom kommt jetzt vom eigenen Dach
Auf {building.strasse} {building.hausnr} läuft bereits eine Solaranlage. Als Mieter:in sichern Sie sich günstigen Strom direkt vom Dach – ohne Umzug, ohne Technikwechsel, in wenigen Minuten beauftragt.
~{saving} €
Ersparnis pro Jahr
{TARIFF.arbeitspreis.replace(' ct/kWh','')}
ct/kWh statt {TARIFF.vergleich.replace(' ct/kWh','')}
Welche Wohnung ist Ihre?
Damit füllen wir Ihren Vertrag automatisch vor.
setUnit(getUnit(building, v))}
options={building.units.map((u) => ({ value: u.we, label: `${u.we} · ${u.etage} · ${u.klingel}` }))} />
Geprüfter Mieterstrom-Anbieter
14 Tage Widerrufsrecht
Monatlich kündbar
);
}
/* ---------- Screen 2 · Daten + Zähler ---------------------------- */
function Screen2({ form, set, errors, building, unit }) {
return (
Schritt 2 · Daten
Ihre Daten & Zählerstand
Adresse, Wohnung und Tarif sind hinterlegt. Wir brauchen nur Ihre Kontaktdaten und den aktuellen Zählerstand.
Bereits für Sie ausgefüllt
Adresse {adresseOf(building)}
Wohneinheit {unit.we} · {unit.etage}
Klingelschild {unit.klingel}
Aktueller Zählerstand
Bitte am Zähler ablesen und fotografieren.
Foto vom Zählerstand
set('zaehlerFoto', d)} error={errors.zaehlerFoto} />
);
}
/* ---------- Screen 3 · Konditionen + Einwilligungen → SEPA ------- */
function ScreenSepa({ form, set, errors, building, unit, onShowContract }) {
return (
Schritt 3 · SEPA-Lastschriftmandat
Konditionen & Zahlung
Konditionen prüfen, zustimmen, dann auf der sicheren Bankseite das SEPA-Mandat erteilen. Danach unterschreiben Sie den Vertrag.
Arbeitspreis {TARIFF.arbeitspreis}
Grundpreis {TARIFF.grundpreis}
Lieferbeginn {building.lieferbeginn}
Laufzeit {TARIFF.laufzeit}
Zählerstand {form.zaehlerstand ? `${form.zaehlerstand} kWh` : '—'}
Vollständigen Vertrag öffnen
Bisheriger Stromvertrag
Optional – wir kümmern uns um die Kündigung.
Im nächsten Schritt richten Sie auf der sicheren Bankseite (GoCardless) Ihr SEPA-Mandat ein. Danach unterschreiben Sie den Vertrag rechtsgültig.
);
}
/* ---------- Screen 4 · Vertrag prüfen + unterschreiben (nach SEPA) */
function ScreenSign({ form, set, errors, building, unit, mandate, mandatePending, demo, onShowContract }) {
const mandateOk = mandate && (mandate.authorised || ['active','submitted','pending_submission'].includes(mandate.mandate_status));
return (
Schritt 4 · Vertrag
Vertrag prüfen & unterschreiben
Letzter Schritt: Vertrag öffnen, prüfen und rechtsgültig unterschreiben.
{(mandateOk && !demo) ?
: }
{demo ? 'SEPA-Mandat (Demo)' : (mandateOk ? 'SEPA-Mandat erteilt' : mandatePending ? 'SEPA-Mandat folgt' : 'SEPA-Mandat in Prüfung')}
{mandatePending && !demo ? 'Wir richten das Lastschriftmandat separat mit Ihnen ein.' : 'Zahlung per SEPA-Lastschrift eingerichtet.'}
Vertrag {TARIFF.name}
Wohneinheit {unit.we} · {building.strasse} {building.hausnr}
Arbeitspreis {TARIFF.arbeitspreis}
Lieferbeginn {building.lieferbeginn}
Vollständigen Vertrag öffnen
Unterschrift
{form.vorname} {form.nachname} · {unit.we}, {adresseOf(building)}
set('signature', d)} error={errors.signature} />
);
}
/* ---------- Success ---------------------------------------------- */
function Screen4({ form, building, unit, mandate, mandatePending, demo, mode, onRestart }) {
const mandateOk = mandate && (mandate.authorised || ['active','submitted','pending_submission'].includes(mandate.mandate_status));
return (
Vertrag abgeschlossen 🎉
{form.vorname ? `${form.vorname}, Ihre` : 'Ihre'} Wohnung {unit ? unit.we : ''} {building ? ` in ${building.strasse} ${building.hausnr}` : ''} ist registriert.
{demo ? 'SEPA (Demo)' : mandatePending ? 'SEPA-Mandat folgt' : (mandateOk ? 'SEPA-Mandat erteilt' : 'SEPA-Mandat in Prüfung')}
Vertrag unterschrieben
1 SEPA-Lastschriftmandat {mandatePending ? '– wir richten es gemeinsam mit Ihnen ein.' : (mandateOk ? 'erteilt.' : 'wird bei Ihrer Bank aktiviert.')}
2 Vertrag & Unterschrift erfasst – Bestätigung mit Vertrag kommt per E-Mail an {form.email || 'Ihre Adresse'}.
3 Lieferbeginn {building ? building.lieferbeginn : ''} – ab dann fließt Solarstrom vom Dach. Wir kündigen Ihren alten Vertrag{form.anbieter ? ` bei ${form.anbieter}` : ''}.
{mode === 'rep' &&
Nächster Mieter
}
);
}
/* ---------- Contract preview modal (popup-blocked fallback) ------ */
function ContractModal({ html, onClose }) {
return (
e.stopPropagation()}>
Vertragsvorschau
×
{html ?
:
Vertrag wird geladen…
}
);
}
/* ---------- Root controller -------------------------------------- */
const EMPTY = { vorname:'', nachname:'', email:'', telefon:'', zaehlernr:'', zaehlerstand:'', ablesedatum:'', zaehlerFoto:'', anbieter:'', kundennr:'', dsgvo:false, v_agb:false, v_widerruf:false, v_sepa:false, signature:'' };
function App() {
const qs = new URLSearchParams(location.search);
const ret = qs.get('step'); // sepa_ok | sepa_abbruch | null
const retOid = qs.get('oid') || (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('pm_oid') : null);
let ctx = null;
try { ctx = retOid ? JSON.parse(sessionStorage.getItem('pm_ctx_' + retOid) || 'null') : null; } catch (e) {}
const mode = qs.get('mode') === 'rep' ? 'rep' : 'self';
const building0 = ctx ? getBuilding(ctx.buildingId) : getBuilding(qs.get('b'));
const unit0 = ctx ? getUnit(building0, ctx.we) : (qs.get('we') ? getUnit(building0, qs.get('we')) : null);
const resuming = ret === 'sepa_ok' && ctx;
const [step, setStep] = useState(resuming ? 4 : 1);
const [done, setDone] = useState(false);
const [maxReached, setMaxReached] = useState(resuming ? 4 : 1);
const [unit, setUnit] = useState(unit0);
const [building] = useState(building0);
const [form, setForm] = useState(ctx ? { ...EMPTY, ...ctx.form, signature: '' } : { ...EMPTY, ablesedatum: TODAY });
const [errors, setErrors] = useState({});
const [busy, setBusy] = useState(false);
const [submitErr, setSubmitErr] = useState(ret === 'sepa_abbruch' ? 'SEPA-Mandat abgebrochen. Bitte erneut starten.' : '');
const [mandate, setMandate] = useState(null);
const [mandatePending, setMandatePending] = useState(false);
const [demo, setDemo] = useState(false);
const [contractOpen, setContractOpen] = useState(false);
const [contractHtml, setContractHtml] = useState(null);
useEffect(() => { if (unit && !form.zaehlernr) setForm((f) => ({ ...f, zaehlernr: unit.zaehler })); }, [unit]);
// On return from GoCardless: read mandate status (form is restored from ctx; user now signs).
useEffect(() => {
if (!resuming) return;
(async () => {
const tq = (ctx && ctx.status_token) ? `&t=${encodeURIComponent(ctx.status_token)}` : '';
try { const s = await fetch(`./api/gocardless-status.php?oid=${encodeURIComponent(retOid)}${tq}`).then(r => r.json()); setMandate(s); } catch (e) {}
try { history.replaceState(null, '', location.pathname); } catch (e) {} // keep pm_oid in storage for finalize
})();
}, []);
const set = useCallback((name, value) => {
setForm((f) => ({ ...f, [name]: value }));
setErrors((e) => (e[name] ? { ...e, [name]: undefined } : e));
}, []);
const go = (n) => { setStep(n); setMaxReached((m) => Math.max(m, n)); window.scrollTo({ top: 0, behavior: 'smooth' }); };
const validate = (n) => {
const e = {};
if (n === 1 && !unit) e.we = 'Bitte Wohnung wählen';
if (n === 2) {
if (!form.vorname.trim()) e.vorname = 'Pflichtfeld';
if (!form.nachname.trim()) e.nachname = 'Pflichtfeld';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)) e.email = 'Gültige E-Mail nötig';
if (form.telefon.replace(/\D/g,'').length < 6) e.telefon = 'Gültige Nummer nötig';
if (!(form.zaehlernr || (unit && unit.zaehler))) e.zaehlernr = 'Pflichtfeld';
if (!/\d/.test(form.zaehlerstand || '')) e.zaehlerstand = 'Zählerstand eingeben';
if (!form.zaehlerFoto) e.zaehlerFoto = 'Bitte ein Foto des Zählerstands aufnehmen';
}
if (n === 3) {
if (!form.v_agb) e.v_agb = 'Erforderlich';
if (!form.v_widerruf) e.v_widerruf = 'Erforderlich';
if (!form.dsgvo) e.dsgvo = 'Erforderlich';
if (!form.v_sepa) e.v_sepa = 'Erforderlich';
}
setErrors(e);
return Object.keys(e).length === 0;
};
const payload = (oid) => {
const { signature, zaehlerFoto, ...formLite } = form;
return {
onboarding_id: oid || null,
building: { id: building.id, name: building.name, adresse: adresseOf(building), lieferbeginn: building.lieferbeginn },
unit: { we: unit.we, etage: unit.etage, klingel: unit.klingel, zaehler: unit.zaehler },
meter: { nummer: form.zaehlernr || (unit && unit.zaehler), stand: form.zaehlerstand, ablesedatum: form.ablesedatum, foto: zaehlerFoto },
signature, tariff: TARIFF, form: formLite, mode, ts: new Date().toISOString(),
};
};
const openContract = async () => {
const w = window.open('', '_blank');
if (w) { try { w.document.write('Mieterstrom-Vertrag Vertrag wird geladen…'); } catch (e) {} }
const showDoc = (html) => { if (w) { try { w.document.open(); w.document.write(html); w.document.close(); return; } catch (e) {} } setContractHtml(html); setContractOpen(true); };
if (location.protocol === 'file:') { showDoc(' Die Vertragsvorschau wird im Live-Betrieb vom Server gerendert.'); return; }
try { showDoc(await fetch('./api/contract-preview.php', { method:'POST', headers:JSON_HEADERS, body: JSON.stringify(payload()) }).then(r => r.text())); }
catch (e) { showDoc(' Vorschau nicht verfügbar.'); }
};
// Step 3 → set up SEPA mandate first (GoCardless), then go to the signing step.
const startSepa = async () => {
if (!validate(3)) { window.scrollTo({ top: 0, behavior: 'smooth' }); return; }
setBusy(true); setSubmitErr('');
const toSign = (pending) => { setMandatePending(pending); setBusy(false); go(4); };
if (location.protocol === 'file:') { setDemo(true); toSign(true); return; }
try {
const gc = await fetch('./api/gocardless-create.php', { method:'POST', headers:JSON_HEADERS, body: JSON.stringify({
form: { vorname: form.vorname, nachname: form.nachname, email: form.email }, building: { adresse: adresseOf(building) },
}) }).then(r => r.json()).catch(() => null);
if (gc && gc.ok && gc.authorisation_url) {
const oid = gc.onboarding_id;
sessionStorage.setItem('pm_oid', oid);
sessionStorage.setItem('pm_ctx_' + oid, JSON.stringify({ buildingId: building.id, we: unit.we, form, status_token: gc.status_token }));
window.location.href = gc.authorisation_url; // off-site → GoCardless SEPA page
return;
}
toSign(true); // GoCardless unavailable → finish the contract, set mandate up separately
} catch (e) { toSign(true); }
};
// Step 4 → contract is signed; finalize (create record + signed contract + email).
const finalizeContract = async () => {
if (!form.signature) { setErrors({ signature: 'Bitte unterschreiben' }); window.scrollTo({ top: 0, behavior: 'smooth' }); return; }
setBusy(true); setSubmitErr('');
if (location.protocol === 'file:') { await new Promise((r) => setTimeout(r, 500)); setDemo(true); setBusy(false); setDone(true); window.scrollTo({ top: 0 }); return; }
let oid = null; try { oid = sessionStorage.getItem('pm_oid'); } catch (e) {}
try {
const res = await fetch('./api/submit.php', { method:'POST', headers:JSON_HEADERS, body: JSON.stringify(payload(oid)) }).then(r => r.json());
if (!res.ok) throw new Error(res.error || 'Abschluss fehlgeschlagen');
try { Object.keys(sessionStorage).filter(k => k.startsWith('pm_')).forEach(k => sessionStorage.removeItem(k)); } catch (e) {}
setBusy(false); setDone(true); window.scrollTo({ top: 0 });
} catch (e) { setSubmitErr('Abschluss fehlgeschlagen: ' + e.message); setBusy(false); }
};
const next = () => { if (!validate(step)) { window.scrollTo({ top: 0, behavior: 'smooth' }); return; } if (step < 3) go(step + 1); };
const restart = () => {
try { Object.keys(sessionStorage).filter(k => k.startsWith('pm_')).forEach(k => sessionStorage.removeItem(k)); } catch (e) {}
history.replaceState(null, '', location.pathname + (mode === 'rep' ? `?mode=rep&b=${building.id}` : `?b=${building.id}`));
setForm({ ...EMPTY, ablesedatum: TODAY }); setUnit(null); setErrors({}); setMandate(null); setDemo(false); setMandatePending(false);
setSubmitErr(''); setDone(false); setStep(1); setMaxReached(1); window.scrollTo({ top: 0 });
};
const toggleTheme = () => {
const cur = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const nx = cur === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', nx);
try { localStorage.setItem('pm_theme', JSON.stringify(nx)); } catch (e) {}
};
const showCta = ((step === 1 && mode === 'self') || step === 2 || step === 3 || (step === 4 && !done));
const primary = step === 3 ? startSepa : step === 4 ? finalizeContract : next;
return (
{mode === 'rep' &&
Vertriebsmodus {building.name}{unit ? ` · ${unit.we}` : ''}
}
{!done && (
step < 4 && n < step && go(n)} />
)}
{step === 1 && go(2)} />}
{step === 2 && }
{step === 3 && }
{step === 4 && !done && }
{step === 4 && done && }
{showCta && (
{(step === 2 || step === 3) ?
go(step - 1)}> Zurück
:
Sichere Datenübertragung }
{submitErr &&
{submitErr}
}
{step === 3 ? <>Weiter zum SEPA-Mandat> : step === 4 ? <>Kostenpflichtig abschließen> : <>Weiter>}
)}
{contractOpen &&
setContractOpen(false)} />}
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );