diff --git a/core/revocation.class.php b/core/revocation.class.php index 598fef6..3da386e 100644 --- a/core/revocation.class.php +++ b/core/revocation.class.php @@ -444,4 +444,183 @@ class Revocation { } return $out; } + + // ============================================================ + // Anonymous-Pfad (§ 356a BGB – Button im Footer, ohne Token) + // ============================================================ + + /** + * Vorausfüllung des anonymen Formulars aus einem Token-Link. + * Liefert ['name','order_number','email'] oder false. + */ + public function prefill_from_token($token_plain) { + if (!REVOCATION_ENABLED) { + $this->error = 'feature_disabled'; + return false; + } + $token_plain = trim((string)$token_plain); + if (strlen($token_plain) !== REVOCATION_TOKEN_BYTES * 2) { + $this->error = 'invalid_token'; + return false; + } + $hash = RevocationHelper::hash_token($token_plain); + $esc = $this->db->real_escape_string($hash); + $sql = "SELECT r.customer_email, + o.order_number, + o.billing_firstname, o.billing_surname + FROM revocations r + JOIN orders o ON o.id = r.order_id + WHERE r.token_hash = '$esc' + AND r.status = 'pending' + LIMIT 1"; + $res = $this->db->query($sql); + if (!$res || $res->num_rows === 0) { + $this->error = 'invalid_token'; + return false; + } + $row = $res->fetch_object(); + return [ + 'name' => trim(($row->billing_firstname ?? '').' '.($row->billing_surname ?? '')), + 'order_number' => $row->order_number, + 'email' => $row->customer_email, + ]; + } + + /** + * Widerruf ohne Token – Lookup anhand Bestellnr + E-Mail. + * Wenn noch kein revocation-Record existiert, wird er inline angelegt. + */ + public function submit_anonymous($name, $order_number, $email, $reason_id, $reason_text, $ip, $user_agent) { + if (!REVOCATION_ENABLED) { + $this->error = 'feature_disabled'; + return false; + } + + $order_number = trim((string)$order_number); + $email = trim(strtolower((string)$email)); + + if ($order_number === '') { + $this->error = 'missing_order'; + return false; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error = 'invalid_email'; + return false; + } + + $order = $this->fetch_order_by_number_email($order_number, $email); + if (!$order) { + $this->error = 'order_not_found'; + return false; + } + + $group_id = RevocationHelper::resolve_group_id($this->db, $order->customer_id); + if (!RevocationHelper::is_b2c($group_id)) { + $this->error = 'not_eligible'; + return false; + } + + $expires_at = RevocationHelper::calc_expires_at($order->order_date); + if (strtotime($expires_at) < time()) { + $this->error = 'expired'; + return false; + } + + // Existing Eintrag wiederverwenden, sonst inline anlegen + $existing = $this->fetch_revocation_by_order((int)$order->id); + if ($existing) { + if ($existing->status !== 'pending') { + $this->error = 'already_submitted'; + return false; + } + $revocation_id = (int)$existing->id; + } else { + $token_plain = RevocationHelper::generate_token(); + $token_hash = RevocationHelper::hash_token($token_plain); + $stmt = $this->db->prepare( + "INSERT INTO revocations + (order_id, customer_id, customer_email, customer_group_id, + token_hash, expires_at, status) + VALUES (?, ?, ?, ?, ?, ?, 'pending')" + ); + if (!$stmt) { + $this->error = 'prepare failed: '.$this->db->error; + return false; + } + $customer_id = $order->customer_id ? (int)$order->customer_id : null; + $order_id = (int)$order->id; + $stmt->bind_param( + 'iisiss', + $order_id, $customer_id, $order->customer_email, $group_id, + $token_hash, $expires_at + ); + if (!$stmt->execute()) { + $this->error = 'insert failed: '.$stmt->error; + $stmt->close(); + return false; + } + $revocation_id = (int)$this->db->insert_id; + $stmt->close(); + } + + $reason_id = ((int)$reason_id > 0) ? (int)$reason_id : null; + $reason_text = trim((string)$reason_text); + if (mb_strlen($reason_text) > 2000) { + $reason_text = mb_substr($reason_text, 0, 2000); + } + $ip_anon = RevocationHelper::anonymize_ip($ip); + $ua = $user_agent ? mb_substr($user_agent, 0, 255) : null; + + $stmt = $this->db->prepare( + "UPDATE revocations + SET status = 'submitted', + submitted_at = NOW(), + reason_id = ?, + reason_text = ?, + ip_anonymized = ?, + user_agent = ? + WHERE id = ? AND status = 'pending'" + ); + if (!$stmt) { + $this->error = 'prepare failed: '.$this->db->error; + return false; + } + $stmt->bind_param('isssi', $reason_id, $reason_text, $ip_anon, $ua, $revocation_id); + $ok = $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + if (!$ok || $affected < 1) { + $this->error = 'update failed'; + return false; + } + + $fresh = $this->fetch_revocation($revocation_id); + if ($fresh) { + $this->send_owner_notification($fresh); + $this->send_customer_confirmation($fresh); + } + return true; + } + + private function fetch_order_by_number_email($order_number, $email) { + $on = $this->db->real_escape_string($order_number); + $em = $this->db->real_escape_string($email); + $sql = "SELECT id, order_number, order_date, order_total, customer_id, + customer_email, billing_firstname, billing_surname, + payment_method + FROM orders + WHERE order_number = '$on' + AND LOWER(customer_email) = '$em' + LIMIT 1"; + $res = $this->db->query($sql); + if (!$res || $res->num_rows === 0) return false; + return $res->fetch_object(); + } + + private function fetch_revocation_by_order($order_id) { + $order_id = (int)$order_id; + $res = $this->db->query("SELECT * FROM revocations WHERE order_id = $order_id LIMIT 1"); + if (!$res || $res->num_rows === 0) return false; + return $res->fetch_object(); + } } diff --git a/widerruf.php b/widerruf.php index 5e9e1b2..1b6bc02 100644 --- a/widerruf.php +++ b/widerruf.php @@ -1,11 +1,12 @@ → Token validieren, Widerrufs-Formular anzeigen - * POST → Widerruf erfassen, Bestätigungsseite anzeigen + * GET → Leeres Formular + * GET ?t= → Formular mit vorausgefüllten Feldern (Convenience aus Bestell-Mail) + * POST → Widerruf erfassen, Bestätigungsseite anzeigen * - * Standalone-Entry-Point, nutzt das Shop-Base-Objekt für DB + Config. + * Submit läuft IMMER über Bestellnr + E-Mail Lookup, nicht über Token. * * @copyright Wlanium / Thomas Bartelt * @since 2026-04-19 @@ -26,11 +27,10 @@ $base_object = new base(); Registry::set('base', $base_object); include_once './core/revocation.class.php'; - $revocation = new Revocation($base_object); $view = 'form'; -$payload = null; +$prefill = ['name' => '', 'order_number' => '', 'email' => '']; $error = null; // ------------------------------------------------------------ @@ -39,48 +39,69 @@ $error = null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $token = isset($_POST['t']) ? (string)$_POST['t'] : ''; - $reason_id = isset($_POST['reason_id']) ? (int)$_POST['reason_id'] : 0; - $reason_text = isset($_POST['reason_text']) ? (string)$_POST['reason_text'] : ''; - $confirm = isset($_POST['confirm']) && $_POST['confirm'] === '1'; + $form_name = isset($_POST['name']) ? trim((string)$_POST['name']) : ''; + $form_order_number = isset($_POST['order_number']) ? trim((string)$_POST['order_number']) : ''; + $form_email = isset($_POST['email']) ? trim((string)$_POST['email']) : ''; + $reason_id = isset($_POST['reason_id']) ? (int)$_POST['reason_id'] : 0; + $reason_text = isset($_POST['reason_text']) ? (string)$_POST['reason_text'] : ''; + $confirm = isset($_POST['confirm']) && $_POST['confirm'] === '1'; + + // Eingaben für Fehlerfall ins Formular zurückspielen + $prefill = [ + 'name' => $form_name, + 'order_number' => $form_order_number, + 'email' => $form_email, + ]; if (!$confirm) { - // Zweite Stufe nicht aktiv bestätigt → zurück zum Form mit Hinweis - $payload = $revocation->validate_token($token); - if ($payload) { - $view = 'form'; - $error = 'Bitte bestätigen Sie den Widerruf durch Setzen des Hakens.'; - } else { - $view = 'error'; - $error = $revocation->get_error(); - } + $view = 'form'; + $error = 'no_confirm'; } else { - $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; - if ($revocation->submit($token, $reason_id, $reason_text, $ip, $ua)) { + if ($revocation->submit_anonymous( + $form_name, $form_order_number, $form_email, + $reason_id, $reason_text, $ip, $ua + )) { $view = 'success'; } else { - $view = 'error'; + $view = 'form'; $error = $revocation->get_error(); } } } else { - $token = isset($_GET['t']) ? (string)$_GET['t'] : ''; - $payload = $revocation->validate_token($token); - if (!$payload) { - $view = 'error'; - $error = $revocation->get_error(); + // GET — bei Token-Link: Felder vorausfüllen + $token = isset($_GET['t']) ? (string)$_GET['t'] : ''; + if ($token !== '') { + $pf = $revocation->prefill_from_token($token); + if ($pf) { + $prefill = $pf; + } elseif ($revocation->get_error() === 'feature_disabled') { + $view = 'error'; + $error = 'feature_disabled'; + } + // Bei anderen Token-Fehlern: leeres Formular anbieten ohne Hinweistext } } -// ------------------------------------------------------------ -// View rendering -// ------------------------------------------------------------ - $reasons = $revocation->fetch_reasons(); +$messages = [ + 'no_confirm' => 'Bitte bestätigen Sie den Widerruf durch Setzen des Hakens.', + 'missing_order' => 'Bitte geben Sie Ihre Bestellnummer an.', + 'invalid_email' => 'Bitte geben Sie eine gültige E-Mail-Adresse an.', + 'order_not_found' => 'Wir konnten keine Bestellung mit dieser Bestellnummer und E-Mail-Adresse finden. ' + . 'Bitte prüfen Sie Ihre Eingaben.', + 'expired' => 'Die 14-tägige Widerrufsfrist ist überschritten. ' + . 'Wenden Sie sich bitte direkt an uns.', + 'already_submitted' => 'Für diese Bestellung wurde bereits ein Widerruf erklärt.', + 'feature_disabled' => 'Die elektronische Widerrufsfunktion ist aktuell noch nicht aktiv.', + 'not_eligible' => 'Für diese Bestellung steht der elektronische Widerruf nicht zur Verfügung.', +]; +$display_error = isset($messages[$error]) ? $messages[$error] : null; + ?> @@ -98,10 +119,10 @@ $reasons = $revocation->fetch_reasons(); .meta{background:#f4f4f4;padding:12px 16px;border-left:4px solid #c00; font-size:.9em;margin:16px 0;} label{display:block;margin:12px 0 4px;font-weight:600;} - select,textarea,input[type=text]{width:100%;padding:10px;font-size:1em; + select,textarea,input[type=text],input[type=email]{width:100%;padding:10px;font-size:1em; border:1px solid #ccc;border-radius:4px;box-sizing:border-box;} textarea{min-height:90px;font-family:inherit;} - .check{margin:20px 0;font-size:.95em;} + .check{margin:20px 0;font-size:.95em;font-weight:normal;} .btn{background:#c00;color:#fff;border:0;padding:14px 28px; font-size:1.05em;font-weight:bold;border-radius:4px;cursor:pointer;} .btn:hover{background:#a00;} @@ -117,22 +138,30 @@ $reasons = $revocation->fetch_reasons(); -

Widerruf Ihrer Bestellung

+

Vertrag widerrufen

Gemäß § 355 BGB haben Sie das Recht, Ihren Vertrag binnen 14 Tagen - ohne Angabe von Gründen zu widerrufen.

+ ohne Angabe von Gründen zu widerrufen. Füllen Sie bitte die folgenden + drei Pflichtfelder aus.

-
- Bestellnummer: order_number ?? ('#'.$payload->order_id)) ?>
- Bestelldatum: order_date ?? '–') ?>
- E-Mail: customer_email) ?> -
+
-
+
- - + + - + + + + + + + + placeholder="Sie können uns hier eine Mitteilung hinterlassen – muss nicht."> - +

Nach dem Absenden erhalten Sie eine Bestätigungs-E-Mail mit der Rücksendeadresse und weiteren Informationen.

@@ -169,22 +198,13 @@ $reasons = $revocation->fetch_reasons();

Widerruf nicht möglich

- 'Der Widerrufs-Link ist ungültig oder nicht (mehr) vorhanden.', - 'expired' => 'Der Widerrufs-Link ist abgelaufen. Die 14-tägige Widerrufsfrist ' - . 'zzgl. Puffer wurde überschritten. Wenden Sie sich bitte direkt an uns.', - 'already_submitted'=> 'Für diese Bestellung wurde bereits ein Widerruf erklärt.', - 'feature_disabled' => 'Die elektronische Widerrufsfunktion ist aktuell noch nicht aktiv.', - 'not_eligible' => 'Für diese Bestellung steht der elektronische Widerruf nicht zur Verfügung.', - ]; - $msg = $messages[$error] ?? 'Widerruf zurzeit nicht verarbeitbar.'; - ?> -
+

Sie können Ihren Widerruf jederzeit auch formlos per E-Mail an oder per Brief erklären.

-

Intelectra GmbH · Sachsenring 20 · 94315 Straubing

+

· + · +