commit 0691fa7f2208f9a1cfa4dedc20f32c6225b8bc1c Author: Arne Weiss Date: Mon Jun 1 08:08:31 2026 +0200 Add simple_withdrawalbutton PrestaShop module diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d4ac6c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +PrestaShop module (v0.1.0) implementing an electronic withdrawal declaration form for German B2C e-commerce. Fulfills EU consumer protection law requirements (Widerrufsrecht) by providing a documented receipt of withdrawal requests. + +**Compatibility**: PrestaShop 1.7.8+ / 8 / 9, PHP 7.2+ + +## No Build System + +This is a classical PrestaShop legacy module with no npm, Composer, Makefile, or test framework. Deployment is via ZIP upload in PrestaShop Admin → Module Manager. + +## Architecture + +### Core Components + +| File | Role | +|------|------| +| `simple_withdrawalbutton.php` | Main module class: install/uninstall, config form, hook handlers, utility methods | +| `controllers/front/withdraw.php` | Public front controller for the withdrawal form (no auth required) | +| `controllers/admin/AdminSimpleWithdrawalController.php` | Backoffice list/detail view with status management | + +### Request Flow + +1. Customer clicks footer link (`displayFooter` hook) or account dashboard link (`displayCustomerAccount` hook) +2. `controllers/front/withdraw.php` handles two POST steps: + - **Step 1** (`prepare`): validate + render `confirm.tpl` + - **Step 2** (`confirm`): write to DB, send emails, render `success.tpl` +3. Two emails sent: customer confirmation + shop notification (configured admin email) + +### Database + +Single table `ps_cyp_withdrawal_request`. Schema is defined inline in `simple_withdrawalbutton.php::install()` — no separate SQL files. Records are intentionally **not deleted on uninstall** (legal compliance). + +Key fields: `order_reference`, `withdrawal_scope` (ENUM: full|partial), `status` (ENUM: new|processing|closed), `customer_ip_hash` / `user_agent_hash` (SHA256, privacy). + +### Security Model + +- CSRF token stored in session, validated on confirm step +- Honeypot field (`website` input, must be empty) +- Rate limiting: configurable max requests per email+IP hash per hour (default 5) +- All DB queries use PrestaShop's `pSQL()` parameterization +- IP and user-agent are hashed (SHA256) before storage + +### Templates + +Smarty templates in `views/templates/front/` (form.tpl, confirm.tpl, success.tpl) and `views/templates/hook/`. Email templates in `mails/de/` and `mails/en/`. + +The items field in `form.tpl` is conditionally shown via vanilla JS only when `withdrawal_scope === 'partial'`. + +## PrestaShop Conventions + +- Use `pSQL()` for all DB string interpolation; `(int)` cast for integers +- Hook registration/deregistration in `install()`/`uninstall()` +- Admin tab registered as `AdminSimpleWithdrawal` — the controller class name must match +- Module config stored via `Configuration::get/updateValue()` +- Email sending uses `Mail::Send()` with template files in `mails/{lang}/` diff --git a/README.md b/README.md new file mode 100644 index 0000000..750e0e6 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# simple_withdrawalbutton + +Minimales PrestaShop-Modul für eine elektronische Widerrufsfunktion (Widerrufsrecht nach § 312k / § 356 BGB). + +**[→ Aktuelle Version herunterladen](https://git.arne-weiss.de/arne/simple-withdrawalbutton/releases/latest)** + +## Enthaltene Funktionen + +- Footer-Link **„Vertrag widerrufen"** über `displayFooter` +- zusätzlicher Link im Kundenkonto über `displayCustomerAccount` +- öffentliches Formular ohne Login-Zwang +- vollständiger Widerruf oder Teilwiderruf per Freitext +- zweistufiger Ablauf: Angaben prüfen → **„Widerruf bestätigen"** +- Speicherung in eigener Tabelle `ps_simple_withdrawal_request` +- automatische Eingangsbestätigung per E-Mail an den Verbraucher +- interne Benachrichtigung per E-Mail an den Shopbetreiber +- einfache Backoffice-Liste unter **Bestellungen → Widerrufe** +- Statusverwaltung: `new`, `processing`, `closed` +- CSRF-Token, Honeypot und konfigurierbares Rate-Limit + +## Installation + +1. ZIP-Datei von der [Releases-Seite](https://git.arne-weiss.de/arne/simple-withdrawalbutton/releases/latest) herunterladen. +2. In PrestaShop unter **Module → Module Manager → Modul hochladen** hochladen. +3. Modul installieren und aktivieren. + +## Konfiguration + +Nach der Installation unter **Module → Module Manager → simple_withdrawalbutton → Konfigurieren**: + +| Einstellung | Beschreibung | +|-------------|--------------| +| Shop-Benachrichtigungs-E-Mail | Adresse, an die neue Widerrufe gemeldet werden | +| Rate-Limit pro Stunde | Max. Einreichungen pro E-Mail oder IP-Hash (Standard: 5) | +| Datenschutzerklärung URL | Link zur Datenschutzerklärung im Formular (DSGVO Art. 13) | +| Widerrufsbelehrung URL | Link zur Widerrufsbelehrung oberhalb des Formulars | +| Aufbewahrungsfrist (Monate) | Ältere Einträge können manuell gelöscht werden (0 = unbegrenzt) | + +## Test nach der Installation + +1. Frontend-Link im Footer oder im Kundenkonto öffnen. +2. Testwiderruf ausfüllen und bestätigen. +3. Prüfen, ob Kunden-E-Mail und Shop-Benachrichtigung ankommen. +4. Im Backoffice unter **Bestellungen → Widerrufe** prüfen, ob der Datensatz sichtbar ist. + +## Wichtige Hinweise + +- Das Modul bestätigt nur den **Eingang** des Widerrufs, nicht dessen rechtliche Wirksamkeit. +- Die Widerrufsbelehrung, Datenschutzerklärung und AGB müssen separat gepflegt werden. +- Beim Deinstallieren löscht das Modul die gespeicherten Widerrufe **nicht** — diese können rechtlich relevant sein. +- Das Modul erzeugt keine Retourenlabels und führt keine automatische Erstattung aus. + +## Kompatibilität + +Klassisches PrestaShop-Legacy-Modul für PrestaShop 1.7.8+ / 8 / 9, PHP 7.2+. Vor Live-Nutzung in einer Staging-Umgebung testen. diff --git a/controllers/admin/AdminSimpleWithdrawalController.php b/controllers/admin/AdminSimpleWithdrawalController.php new file mode 100644 index 0000000..f91e7fa --- /dev/null +++ b/controllers/admin/AdminSimpleWithdrawalController.php @@ -0,0 +1,153 @@ +bootstrap = true; + $this->table = 'simple_withdrawal_request'; + $this->identifier = 'id_withdrawal_request'; + $this->lang = false; + $this->explicitSelect = true; + $this->_select = 'a.*'; + $this->_defaultOrderBy = 'created_at'; + $this->_defaultOrderWay = 'DESC'; + $this->allow_export = true; + $this->list_no_link = false; + $this->actions = ['view']; + + $this->fields_list = [ + 'id_withdrawal_request' => [ + 'title' => $this->l('ID'), + 'align' => 'center', + 'class' => 'fixed-width-xs', + ], + 'created_at' => [ + 'title' => $this->l('Received'), + 'type' => 'datetime', + 'filter_key' => 'a!created_at', + ], + 'order_reference' => [ + 'title' => $this->l('Order reference'), + 'filter_key' => 'a!order_reference', + ], + 'customer_name' => [ + 'title' => $this->l('Name'), + 'filter_key' => 'a!customer_name', + ], + 'customer_email' => [ + 'title' => $this->l('Email'), + 'filter_key' => 'a!customer_email', + ], + 'withdrawal_scope' => [ + 'title' => $this->l('Scope'), + 'callback' => 'renderScopeLabel', + 'callback_object' => $this, + 'filter_key' => 'a!withdrawal_scope', + ], + 'status' => [ + 'title' => $this->l('Status'), + 'callback' => 'renderStatusLabel', + 'callback_object' => $this, + 'filter_key' => 'a!status', + ], + ]; + + parent::__construct(); + } + + public function renderScopeLabel($value, $row) + { + if ($value === 'partial') { + return '' . $this->l('Partial') . ''; + } + + return '' . $this->l('Full order') . ''; + } + + public function renderStatusLabel($value, $row) + { + $labels = [ + 'new' => ['class' => 'label-warning', 'text' => $this->l('New')], + 'processing' => ['class' => 'label-info', 'text' => $this->l('Processing')], + 'closed' => ['class' => 'label-success', 'text' => $this->l('Closed')], + ]; + + $label = isset($labels[$value]) ? $labels[$value] : ['class' => 'label-default', 'text' => $value]; + return '' . Tools::safeOutput($label['text']) . ''; + } + + public function postProcess() + { + if (Tools::isSubmit('submitSimpleWithdrawalStatus')) { + $this->processStatusUpdate(); + return; + } + + parent::postProcess(); + } + + private function processStatusUpdate() + { + $id = (int) Tools::getValue($this->identifier); + $status = (string) Tools::getValue('status'); + $allowedStatuses = ['new', 'processing', 'closed']; + + if ($id <= 0 || !in_array($status, $allowedStatuses, true)) { + $this->errors[] = $this->l('Invalid status update.'); + return; + } + + $ok = Db::getInstance()->update( + 'simple_withdrawal_request', + ['status' => $status], + '`id_withdrawal_request` = ' . (int) $id + ); + + if ($ok) { + Tools::redirectAdmin(self::$currentIndex . '&token=' . $this->token . '&conf=4&view' . $this->table . '=1&' . $this->identifier . '=' . (int) $id); + } + + $this->errors[] = $this->l('The status could not be updated.'); + } + + public function renderView() + { + $id = (int) Tools::getValue($this->identifier); + $row = Db::getInstance()->getRow( + 'SELECT * FROM `' . _DB_PREFIX_ . pSQL('simple_withdrawal_request') . '` + WHERE `id_withdrawal_request` = ' . (int) $id + ); + + if (!$row) { + $this->errors[] = $this->l('Withdrawal request not found.'); + return parent::renderList(); + } + + $orderLink = ''; + if (!empty($row['id_order'])) { + $orderLink = $this->context->link->getAdminLink('AdminOrders', true, [], [ + 'id_order' => (int) $row['id_order'], + 'vieworder' => 1, + ]); + } + + $this->context->smarty->assign([ + 'request' => $row, + 'order_link' => $orderLink, + 'current_index' => self::$currentIndex, + 'token' => $this->token, + 'identifier' => $this->identifier, + 'back_link' => self::$currentIndex . '&token=' . $this->token, + ]); + + return $this->module->display( + _PS_MODULE_DIR_ . $this->module->name . '/' . $this->module->name . '.php', + 'views/templates/admin/view.tpl' + ); + } +} diff --git a/controllers/admin/index.php b/controllers/admin/index.php new file mode 100644 index 0000000..0d10a9e --- /dev/null +++ b/controllers/admin/index.php @@ -0,0 +1,2 @@ +context->smarty->assign([ + 'errors_list' => $this->errorsList, + 'form_data' => $this->formData, + 'csrf_token' => $this->module->getFrontToken(), + 'action_url' => $this->context->link->getModuleLink($this->module->name, 'withdraw', [], true), + 'success_data' => $this->successData, + 'privacy_url' => (string) Configuration::get(Simple_withdrawalbutton::CONF_PRIVACY_URL), + 'revocation_url' => (string) Configuration::get(Simple_withdrawalbutton::CONF_REVOCATION_URL), + ]); + + if ($this->currentView === 'confirm') { + $this->setTemplate('module:' . $this->module->name . '/views/templates/front/confirm.tpl'); + return; + } + + if ($this->currentView === 'success') { + $this->setTemplate('module:' . $this->module->name . '/views/templates/front/success.tpl'); + return; + } + + $this->setTemplate('module:' . $this->module->name . '/views/templates/front/form.tpl'); + } + + public function postProcess() + { + if (Tools::isSubmit('submit_withdrawal_prepare')) { + $this->handlePrepare(); + return; + } + + if (Tools::isSubmit('submit_withdrawal_confirm')) { + $this->handleConfirm(); + return; + } + + if (Tools::isSubmit('submit_withdrawal_back')) { + $this->formData = $this->collectInput(); + $this->currentView = 'form'; + return; + } + + $this->formData = $this->getEmptyFormData(); + } + + private function handlePrepare() + { + $data = $this->collectInput(); + $this->formData = $data; + + if (!$this->module->isValidFrontToken(Tools::getValue('csrf_token'))) { + $this->errorsList[] = $this->module->l('The form could not be validated. Please reload the page and try again.', 'withdraw'); + } + + if ($this->isHoneypotFilled()) { + $this->errorsList[] = $this->module->l('The form could not be validated. Please reload the page and try again.', 'withdraw'); + } + + $this->errorsList = array_merge($this->errorsList, $this->validateData($data)); + + if (!empty($this->errorsList)) { + $this->currentView = 'form'; + return; + } + + $this->currentView = 'confirm'; + } + + private function handleConfirm() + { + $data = $this->collectInput(); + $this->formData = $data; + + if (!$this->module->isValidFrontToken(Tools::getValue('csrf_token'))) { + $this->errorsList[] = $this->module->l('The form could not be validated. Please reload the page and try again.', 'withdraw'); + } + + if ($this->isHoneypotFilled()) { + // Do not save obvious bot submissions. Show a generic success page to avoid feedback loops. + $this->currentView = 'success'; + $this->successData = [ + 'mail_ok' => true, + 'fake' => true, + ]; + return; + } + + $this->errorsList = array_merge($this->errorsList, $this->validateData($data)); + + $ipHash = $this->module->hashForPrivacy($this->module->getClientIp()); + if (empty($this->errorsList) && $this->module->isRateLimited($data['customer_email'], $ipHash)) { + $this->errorsList[] = $this->module->l('Too many withdrawal declarations were submitted in a short period. Please try again later or contact us by email.', 'withdraw'); + } + + if (!empty($this->errorsList)) { + $this->currentView = 'form'; + return; + } + + $saved = $this->module->saveWithdrawal($data); + if (!$saved) { + $this->errorsList[] = $this->module->l('Your withdrawal declaration could not be saved. Please try again or contact us by email.', 'withdraw'); + $this->currentView = 'form'; + return; + } + + $customerMailOk = $this->module->sendCustomerConfirmation($data, $saved['created_at']); + $shopMailOk = $this->module->sendShopNotification($data, $saved['created_at'], (int) $saved['id_withdrawal_request']); + + if ($customerMailOk) { + $this->module->markConfirmationSent((int) $saved['id_withdrawal_request']); + } + + $this->currentView = 'success'; + $this->successData = [ + 'id_withdrawal_request' => (int) $saved['id_withdrawal_request'], + 'created_at' => $this->module->formatDateTimeForMail($saved['created_at']), + 'mail_ok' => (bool) $customerMailOk, + 'shop_mail_ok' => (bool) $shopMailOk, + 'customer_email' => $data['customer_email'], + 'order_reference' => $data['order_reference'], + 'withdrawal_scope' => $data['withdrawal_scope'], + 'withdrawal_items_text' => $data['withdrawal_items_text'], + 'message' => $data['message'], + ]; + } + + private function collectInput() + { + $scope = (string) Tools::getValue('withdrawal_scope'); + if (!in_array($scope, ['full', 'partial'], true)) { + $scope = 'full'; + } + + return [ + 'customer_name' => $this->module->cleanText(Tools::getValue('customer_name'), 255), + 'customer_email' => strtolower($this->module->cleanText(Tools::getValue('customer_email'), 255)), + 'order_reference' => $this->module->cleanText(Tools::getValue('order_reference'), 64), + 'withdrawal_scope' => $scope, + 'withdrawal_items_text' => $this->module->cleanText(Tools::getValue('withdrawal_items_text'), 5000), + 'message' => $this->module->cleanText(Tools::getValue('message'), 5000), + ]; + } + + private function getEmptyFormData() + { + $customerName = ''; + $customerEmail = ''; + + if (Validate::isLoadedObject($this->context->customer)) { + $customerName = trim((string) $this->context->customer->firstname . ' ' . (string) $this->context->customer->lastname); + $customerEmail = (string) $this->context->customer->email; + } + + return [ + 'customer_name' => $customerName, + 'customer_email' => $customerEmail, + 'order_reference' => '', + 'withdrawal_scope' => 'full', + 'withdrawal_items_text' => '', + 'message' => '', + ]; + } + + private function validateData(array $data) + { + $errors = []; + + if ($data['customer_name'] === '' || Tools::strlen($data['customer_name']) < 2) { + $errors[] = $this->module->l('Please enter your name.', 'withdraw'); + } + + if (!Validate::isEmail($data['customer_email'])) { + $errors[] = $this->module->l('Please enter a valid email address for the confirmation.', 'withdraw'); + } + + if ($data['order_reference'] === '') { + $errors[] = $this->module->l('Please enter the order number or order reference.', 'withdraw'); + } + + if (!in_array($data['withdrawal_scope'], ['full', 'partial'], true)) { + $errors[] = $this->module->l('Please select whether you want to withdraw the full order or part of it.', 'withdraw'); + } + + if ($data['withdrawal_scope'] === 'partial' && $data['withdrawal_items_text'] === '') { + $errors[] = $this->module->l('For a partial withdrawal, please enter the affected items and quantities.', 'withdraw'); + } + + return $errors; + } + + private function isHoneypotFilled() + { + return trim((string) Tools::getValue('cyp_hp_v1')) !== ''; + } +} diff --git a/controllers/index.php b/controllers/index.php new file mode 100644 index 0000000..0d10a9e --- /dev/null +++ b/controllers/index.php @@ -0,0 +1,2 @@ + + + + + Eingangsbestätigung Ihres Widerrufs + + +

Guten Tag {customer_name},

+ +

wir bestätigen den Eingang Ihrer Widerrufserklärung.

+ +

+ Eingang des Widerrufs:
+ {submitted_at} +

+ +

+ Bestellnummer / Bestellreferenz:
+ {order_reference} +

+ +

+ Widerruf betrifft:
+ {scope_label} +

+ +

+ Betroffene Artikel / Mengen:
+ {withdrawal_items_text} +

+ +

+ Ihre Nachricht / Bemerkung:
+ {message} +

+ +

+ Diese Nachricht bestätigt nur den Eingang Ihrer Widerrufserklärung. Die weitere Bearbeitung und Prüfung erfolgt separat. +

+ +

Mit freundlichen Grüßen
{shop_name}

+ + diff --git a/mails/de/withdrawal_confirmation.txt b/mails/de/withdrawal_confirmation.txt new file mode 100644 index 0000000..df903b3 --- /dev/null +++ b/mails/de/withdrawal_confirmation.txt @@ -0,0 +1,23 @@ +Guten Tag {customer_name}, + +wir bestätigen den Eingang Ihrer Widerrufserklärung. + +Eingang des Widerrufs: +{submitted_at} + +Bestellnummer / Bestellreferenz: +{order_reference} + +Widerruf betrifft: +{scope_label} + +Betroffene Artikel / Mengen: +{withdrawal_items_text} + +Ihre Nachricht / Bemerkung: +{message} + +Diese Nachricht bestätigt nur den Eingang Ihrer Widerrufserklärung. Die weitere Bearbeitung und Prüfung erfolgt separat. + +Mit freundlichen Grüßen +{shop_name} diff --git a/mails/de/withdrawal_notification.html b/mails/de/withdrawal_notification.html new file mode 100644 index 0000000..231092e --- /dev/null +++ b/mails/de/withdrawal_notification.html @@ -0,0 +1,33 @@ + + + + + Neuer Widerruf + + +

Ein neuer Widerruf ist eingegangen.

+ +

+ ID: {id_withdrawal_request}
+ Eingang: {submitted_at}
+ Bestellnummer / Bestellreferenz: {order_reference}
+ Name: {customer_name}
+ E-Mail: {customer_email}
+ Widerruf betrifft: {scope_label} +

+ +

+ Betroffene Artikel / Mengen:
+ {withdrawal_items_text} +

+ +

+ Nachricht / Bemerkung:
+ {message} +

+ +

+ Backoffice: {admin_link} +

+ + diff --git a/mails/de/withdrawal_notification.txt b/mails/de/withdrawal_notification.txt new file mode 100644 index 0000000..04a6ff9 --- /dev/null +++ b/mails/de/withdrawal_notification.txt @@ -0,0 +1,17 @@ +Ein neuer Widerruf ist eingegangen. + +ID: {id_withdrawal_request} +Eingang: {submitted_at} +Bestellnummer / Bestellreferenz: {order_reference} +Name: {customer_name} +E-Mail: {customer_email} +Widerruf betrifft: {scope_label} + +Betroffene Artikel / Mengen: +{withdrawal_items_text} + +Nachricht / Bemerkung: +{message} + +Backoffice: +{admin_link} diff --git a/mails/en/index.php b/mails/en/index.php new file mode 100644 index 0000000..0d10a9e --- /dev/null +++ b/mails/en/index.php @@ -0,0 +1,2 @@ + + + + + Confirmation of receipt of your withdrawal + + +

Hello {customer_name},

+ +

We confirm receipt of your withdrawal declaration.

+ +

Received:
{submitted_at}

+

Order number / order reference:
{order_reference}

+

Withdrawal concerns:
{scope_label}

+

Affected items / quantities:
{withdrawal_items_text}

+

Your message / note:
{message}

+ +

This message only confirms receipt of your withdrawal declaration. Further processing and review will follow separately.

+ +

Kind regards
{shop_name}

+ + diff --git a/mails/en/withdrawal_confirmation.txt b/mails/en/withdrawal_confirmation.txt new file mode 100644 index 0000000..8cc5bee --- /dev/null +++ b/mails/en/withdrawal_confirmation.txt @@ -0,0 +1,23 @@ +Hello {customer_name}, + +We confirm receipt of your withdrawal declaration. + +Received: +{submitted_at} + +Order number / order reference: +{order_reference} + +Withdrawal concerns: +{scope_label} + +Affected items / quantities: +{withdrawal_items_text} + +Your message / note: +{message} + +This message only confirms receipt of your withdrawal declaration. Further processing and review will follow separately. + +Kind regards +{shop_name} diff --git a/mails/en/withdrawal_notification.html b/mails/en/withdrawal_notification.html new file mode 100644 index 0000000..b940746 --- /dev/null +++ b/mails/en/withdrawal_notification.html @@ -0,0 +1,24 @@ + + + + + New withdrawal declaration + + +

A new withdrawal declaration has been received.

+ +

+ ID: {id_withdrawal_request}
+ Received: {submitted_at}
+ Order number / order reference: {order_reference}
+ Name: {customer_name}
+ Email: {customer_email}
+ Withdrawal concerns: {scope_label} +

+ +

Affected items / quantities:
{withdrawal_items_text}

+

Message / note:
{message}

+ +

Back office: {admin_link}

+ + diff --git a/mails/en/withdrawal_notification.txt b/mails/en/withdrawal_notification.txt new file mode 100644 index 0000000..a56c560 --- /dev/null +++ b/mails/en/withdrawal_notification.txt @@ -0,0 +1,17 @@ +A new withdrawal declaration has been received. + +ID: {id_withdrawal_request} +Received: {submitted_at} +Order number / order reference: {order_reference} +Name: {customer_name} +Email: {customer_email} +Withdrawal concerns: {scope_label} + +Affected items / quantities: +{withdrawal_items_text} + +Message / note: +{message} + +Back office: +{admin_link} diff --git a/mails/index.php b/mails/index.php new file mode 100644 index 0000000..0d10a9e --- /dev/null +++ b/mails/index.php @@ -0,0 +1,2 @@ +name = 'simple_withdrawalbutton'; + $this->tab = 'administration'; + $this->version = '0.1.0'; + $this->author = 'Arne Weiss'; + $this->need_instance = 0; + $this->bootstrap = true; + $this->ps_versions_compliancy = [ + 'min' => '1.7.8.0', + 'max' => _PS_VERSION_, + ]; + + parent::__construct(); + + $this->displayName = $this->l('Withdrawal button'); + $this->description = $this->l('Adds a minimal two-step electronic withdrawal function for B2C orders.'); + $this->confirmUninstall = $this->l('Uninstall the module? Existing withdrawal records will be kept in the database.'); + } + + public function install() + { + return parent::install() + && $this->installSql() + && $this->installTab() + && $this->registerHook('displayFooter') + && $this->registerHook('displayCustomerAccount') + && Configuration::updateValue(self::CONF_SHOP_EMAIL, (string) Configuration::get('PS_SHOP_EMAIL')) + && Configuration::updateValue(self::CONF_RATE_LIMIT, '5') + && Configuration::updateValue(self::CONF_PRIVACY_URL, '') + && Configuration::updateValue(self::CONF_REVOCATION_URL, '') + && Configuration::updateValue(self::CONF_RETENTION_MONTHS, '0'); + } + + public function uninstall() + { + // Keep withdrawal records intentionally. These may be legally relevant. + return $this->uninstallTab() + && Configuration::deleteByName(self::CONF_SHOP_EMAIL) + && Configuration::deleteByName(self::CONF_RATE_LIMIT) + && Configuration::deleteByName(self::CONF_PRIVACY_URL) + && Configuration::deleteByName(self::CONF_REVOCATION_URL) + && Configuration::deleteByName(self::CONF_RETENTION_MONTHS) + && parent::uninstall(); + } + + private function installSql() + { + $sql = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . pSQL(self::TABLE_REQUEST) . '` ( + `id_withdrawal_request` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_order` INT UNSIGNED NULL, + `order_reference` VARCHAR(64) NOT NULL, + `customer_name` VARCHAR(255) NOT NULL, + `customer_email` VARCHAR(255) NOT NULL, + `withdrawal_scope` ENUM("full", "partial") NOT NULL, + `withdrawal_items_text` TEXT NULL, + `message` TEXT NULL, + `created_at` DATETIME NOT NULL, + `confirmation_sent_at` DATETIME NULL, + `status` ENUM("new", "processing", "closed") NOT NULL DEFAULT "new", + `id_shop` INT UNSIGNED NULL, + `id_lang` INT UNSIGNED NULL, + `customer_ip_hash` CHAR(64) NULL, + `user_agent_hash` CHAR(64) NULL, + PRIMARY KEY (`id_withdrawal_request`), + KEY `idx_order_reference` (`order_reference`), + KEY `idx_customer_email` (`customer_email`), + KEY `idx_created_at` (`created_at`), + KEY `idx_status` (`status`), + KEY `idx_shop` (`id_shop`) + ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;'; + + return Db::getInstance()->execute($sql); + } + + private function installTab() + { + $className = 'AdminSimpleWithdrawal'; + if ((int) Tab::getIdFromClassName($className) > 0) { + return true; + } + + $tab = new Tab(); + $tab->active = 1; + $tab->class_name = $className; + $tab->module = $this->name; + $tab->id_parent = $this->getOrdersParentTabId(); + + foreach (Language::getLanguages(false) as $lang) { + $tab->name[(int) $lang['id_lang']] = 'Widerrufe'; + } + + return (bool) $tab->add(); + } + + private function uninstallTab() + { + $idTab = (int) Tab::getIdFromClassName('AdminSimpleWithdrawal'); + if ($idTab <= 0) { + return true; + } + + $tab = new Tab($idTab); + return (bool) $tab->delete(); + } + + private function getOrdersParentTabId() + { + $candidates = [ + 'AdminParentOrders', + 'AdminOrders', + 'AdminParentModulesSf', + ]; + + foreach ($candidates as $className) { + $id = (int) Tab::getIdFromClassName($className); + if ($id > 0) { + return $id; + } + } + + return 0; + } + + public function getContent() + { + $output = ''; + + if (Tools::isSubmit('submitCypWithdrawalSettings')) { + $email = trim((string) Tools::getValue(self::CONF_SHOP_EMAIL)); + $rateLimit = (int) Tools::getValue(self::CONF_RATE_LIMIT); + $privacyUrl = trim((string) Tools::getValue(self::CONF_PRIVACY_URL)); + $revocationUrl = trim((string) Tools::getValue(self::CONF_REVOCATION_URL)); + $retentionMonths = (int) Tools::getValue(self::CONF_RETENTION_MONTHS); + + if (!Validate::isEmail($email)) { + $output .= $this->displayError($this->l('Please enter a valid shop notification email.')); + } else { + Configuration::updateValue(self::CONF_SHOP_EMAIL, $email); + Configuration::updateValue(self::CONF_RATE_LIMIT, (string) max(1, min(50, $rateLimit))); + Configuration::updateValue(self::CONF_PRIVACY_URL, $privacyUrl); + Configuration::updateValue(self::CONF_REVOCATION_URL, $revocationUrl); + Configuration::updateValue(self::CONF_RETENTION_MONTHS, (string) max(0, $retentionMonths)); + $output .= $this->displayConfirmation($this->l('Settings updated.')); + } + } + + if (Tools::isSubmit('submitCypWithdrawalPurge')) { + $retentionMonths = (int) Configuration::get(self::CONF_RETENTION_MONTHS); + if ($retentionMonths > 0) { + $deleted = $this->purgeOldRecords($retentionMonths); + $output .= $this->displayConfirmation( + sprintf($this->l('%d withdrawal record(s) older than %d month(s) have been deleted.'), $deleted, $retentionMonths) + ); + } else { + $output .= $this->displayError($this->l('No retention period configured. Set a value greater than 0 first.')); + } + } + + $adminLink = $this->context->link->getAdminLink('AdminSimpleWithdrawal'); + $withdrawalLink = $this->getWithdrawalLink(); + + $this->context->smarty->assign([ + 'admin_link' => $adminLink, + 'withdrawal_link' => $withdrawalLink, + ]); + + return $output . $this->renderForm() . $this->display(__FILE__, 'views/templates/admin/config_info.tpl'); + } + + private function renderForm() + { + $helper = new HelperForm(); + $helper->show_toolbar = false; + $helper->table = $this->table; + $helper->module = $this; + $helper->default_form_language = (int) Configuration::get('PS_LANG_DEFAULT'); + $helper->allow_employee_form_lang = (int) Configuration::get('PS_BO_ALLOW_EMPLOYEE_FORM_LANG'); + $helper->identifier = $this->name; + $helper->submit_action = 'submitCypWithdrawalSettings'; + $helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name; + $helper->token = Tools::getAdminTokenLite('AdminModules'); + $helper->tpl_vars = [ + 'fields_value' => [ + self::CONF_SHOP_EMAIL => (string) Configuration::get(self::CONF_SHOP_EMAIL), + self::CONF_RATE_LIMIT => (string) Configuration::get(self::CONF_RATE_LIMIT), + self::CONF_PRIVACY_URL => (string) Configuration::get(self::CONF_PRIVACY_URL), + self::CONF_REVOCATION_URL => (string) Configuration::get(self::CONF_REVOCATION_URL), + self::CONF_RETENTION_MONTHS => (string) Configuration::get(self::CONF_RETENTION_MONTHS), + ], + 'languages' => $this->context->controller->getLanguages(), + 'id_language' => (int) $this->context->language->id, + ]; + + $fieldsForm = [ + 'form' => [ + 'legend' => [ + 'title' => $this->l('Withdrawal button settings'), + 'icon' => 'icon-undo', + ], + 'input' => [ + [ + 'type' => 'text', + 'label' => $this->l('Shop notification email'), + 'name' => self::CONF_SHOP_EMAIL, + 'required' => true, + 'desc' => $this->l('New withdrawal declarations are sent to this address.'), + ], + [ + 'type' => 'text', + 'label' => $this->l('Rate limit per hour'), + 'name' => self::CONF_RATE_LIMIT, + 'required' => true, + 'desc' => $this->l('Maximum saved withdrawal declarations per email or IP hash per hour.'), + ], + [ + 'type' => 'text', + 'label' => $this->l('Privacy policy URL'), + 'name' => self::CONF_PRIVACY_URL, + 'required' => false, + 'desc' => $this->l('Link to your Datenschutzerklärung shown in the withdrawal form (GDPR Art. 13). Leave empty to show plain text without link.'), + ], + [ + 'type' => 'text', + 'label' => $this->l('Widerrufsbelehrung URL'), + 'name' => self::CONF_REVOCATION_URL, + 'required' => false, + 'desc' => $this->l('Link to your revocation policy page shown above the withdrawal form. Leave empty to hide the link.'), + ], + [ + 'type' => 'text', + 'label' => $this->l('Retention period (months)'), + 'name' => self::CONF_RETENTION_MONTHS, + 'required' => false, + 'desc' => $this->l('Delete withdrawal records older than this many months when purging. Set to 0 to keep records indefinitely (GDPR: define a retention period).'), + ], + ], + 'buttons' => [ + [ + 'title' => $this->l('Purge old records'), + 'name' => 'submitCypWithdrawalPurge', + 'icon' => 'process-icon-delete', + 'class' => 'btn btn-default pull-right', + ], + ], + 'submit' => [ + 'title' => $this->l('Save'), + ], + ], + ]; + + return $helper->generateForm([$fieldsForm]); + } + + public function hookDisplayFooter(array $params) + { + $this->context->smarty->assign([ + 'withdrawal_link' => $this->getWithdrawalLink(), + ]); + + return $this->display(__FILE__, 'views/templates/hook/footer.tpl'); + } + + public function hookDisplayCustomerAccount(array $params) + { + $this->context->smarty->assign([ + 'withdrawal_link' => $this->getWithdrawalLink(), + ]); + + return $this->display(__FILE__, 'views/templates/hook/customer_account.tpl'); + } + + public function getWithdrawalLink() + { + return $this->context->link->getModuleLink($this->name, 'withdraw', [], true); + } + + public function getFrontToken() + { + $guestId = isset($this->context->cookie->id_guest) ? (string) $this->context->cookie->id_guest : ''; + $customerId = isset($this->context->customer->id) ? (string) $this->context->customer->id : ''; + + return Tools::hash($this->name . '|withdraw|' . $guestId . '|' . $customerId . '|' . _COOKIE_KEY_); + } + + public function isValidFrontToken($token) + { + return hash_equals($this->getFrontToken(), (string) $token); + } + + public function cleanText($value, $maxLength = 255) + { + $value = trim((string) $value); + $value = strip_tags($value); + $value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value); + if ($maxLength > 0 && Tools::strlen($value) > $maxLength) { + $value = Tools::substr($value, 0, $maxLength); + } + + return $value; + } + + public function lookupOrderIdByReference($orderReference) + { + $orderReference = $this->cleanText($orderReference, 64); + if ($orderReference === '') { + return null; + } + + $sql = 'SELECT `id_order` + FROM `' . _DB_PREFIX_ . 'orders` + WHERE `reference` = "' . pSQL($orderReference) . '" + AND `id_shop` = ' . (int) $this->context->shop->id . ' + ORDER BY `id_order` DESC'; + + $idOrder = (int) Db::getInstance()->getValue($sql); + return $idOrder > 0 ? $idOrder : null; + } + + public function hashForPrivacy($value) + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + + return hash('sha256', $value . '|' . _COOKIE_KEY_); + } + + public function getClientIp() + { + if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { + return (string) $_SERVER['HTTP_CF_CONNECTING_IP']; + } + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']); + return trim($parts[0]); + } + if (!empty($_SERVER['REMOTE_ADDR'])) { + return (string) $_SERVER['REMOTE_ADDR']; + } + + return ''; + } + + public function isRateLimited($customerEmail, $ipHash) + { + $limit = (int) Configuration::get(self::CONF_RATE_LIMIT); + if ($limit <= 0) { + $limit = 5; + } + + $conditions = ['`customer_email` = "' . pSQL($customerEmail) . '"']; + if ($ipHash) { + $conditions[] = '`customer_ip_hash` = "' . pSQL($ipHash) . '"'; + } + + $sql = 'SELECT COUNT(*) + FROM `' . _DB_PREFIX_ . pSQL(self::TABLE_REQUEST) . '` + WHERE (`created_at` >= DATE_SUB(NOW(), INTERVAL 1 HOUR)) + AND (' . implode(' OR ', $conditions) . ')'; + + return (int) Db::getInstance()->getValue($sql) >= $limit; + } + + public function saveWithdrawal(array $data) + { + $createdAt = date('Y-m-d H:i:s'); + $ipHash = $this->hashForPrivacy($this->getClientIp()); + $userAgentHash = $this->hashForPrivacy(isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : ''); + + $idOrder = $this->lookupOrderIdByReference($data['order_reference']); + + $insert = [ + 'id_order' => $idOrder ? (int) $idOrder : null, + 'order_reference' => $data['order_reference'], + 'customer_name' => $data['customer_name'], + 'customer_email' => $data['customer_email'], + 'withdrawal_scope' => $data['withdrawal_scope'], + 'withdrawal_items_text' => $data['withdrawal_items_text'] !== '' ? $data['withdrawal_items_text'] : null, + 'message' => $data['message'] !== '' ? $data['message'] : null, + 'created_at' => $createdAt, + 'status' => 'new', + 'id_shop' => (int) $this->context->shop->id, + 'id_lang' => (int) $this->context->language->id, + 'customer_ip_hash' => $ipHash ?: null, + 'user_agent_hash' => $userAgentHash ?: null, + ]; + + $ok = Db::getInstance()->insert(self::TABLE_REQUEST, $insert, false, true, Db::INSERT, false); + if (!$ok) { + return false; + } + + $id = (int) Db::getInstance()->Insert_ID(); + return [ + 'id_withdrawal_request' => $id, + 'created_at' => $createdAt, + 'id_order' => $idOrder, + 'customer_ip_hash' => $ipHash, + ]; + } + + public function markConfirmationSent($idWithdrawalRequest) + { + return Db::getInstance()->update( + self::TABLE_REQUEST, + ['confirmation_sent_at' => date('Y-m-d H:i:s')], + '`id_withdrawal_request` = ' . (int) $idWithdrawalRequest + ); + } + + public function sendCustomerConfirmation(array $data, $createdAt) + { + $idLang = (int) $this->context->language->id; + $idShop = (int) $this->context->shop->id; + $isoCode = $this->getLangIsoCode($idLang); + $shopName = (string) Configuration::get('PS_SHOP_NAME'); + $shopEmail = (string) Configuration::get('PS_SHOP_EMAIL'); + $scopeLabel = $this->getScopeLabel($data['withdrawal_scope'], $isoCode); + + $templateVars = [ + '{customer_name}' => $data['customer_name'], + '{order_reference}' => $data['order_reference'], + '{scope_label}' => $scopeLabel, + '{withdrawal_items_text}' => $data['withdrawal_items_text'] !== '' ? $data['withdrawal_items_text'] : '-', + '{message}' => $data['message'] !== '' ? $data['message'] : '-', + '{submitted_at}' => $this->formatDateTimeForMail($createdAt, $isoCode), + '{shop_name}' => $shopName, + ]; + + try { + return Mail::Send( + $idLang, + 'withdrawal_confirmation', + $this->getConfirmationSubject($isoCode), + $templateVars, + $data['customer_email'], + $data['customer_name'], + $shopEmail, + $shopName, + null, + null, + _PS_MODULE_DIR_ . $this->name . '/mails/', + false, + $idShop + ); + } catch (\Exception $e) { + return false; + } + } + + public function sendShopNotification(array $data, $createdAt, $idWithdrawalRequest) + { + $idLang = (int) $this->context->language->id; + $idShop = (int) $this->context->shop->id; + $isoCode = $this->getLangIsoCode($idLang); + $shopName = (string) Configuration::get('PS_SHOP_NAME'); + $shopEmail = (string) Configuration::get('PS_SHOP_EMAIL'); + $toEmail = (string) Configuration::get(self::CONF_SHOP_EMAIL); + if (!Validate::isEmail($toEmail)) { + $toEmail = $shopEmail; + } + + $scopeLabel = $this->getScopeLabel($data['withdrawal_scope'], $isoCode); + $adminLink = $this->context->link->getAdminLink('AdminSimpleWithdrawal'); + + $templateVars = [ + '{id_withdrawal_request}' => (string) $idWithdrawalRequest, + '{customer_name}' => $data['customer_name'], + '{customer_email}' => $data['customer_email'], + '{order_reference}' => $data['order_reference'], + '{scope_label}' => $scopeLabel, + '{withdrawal_items_text}' => $data['withdrawal_items_text'] !== '' ? $data['withdrawal_items_text'] : '-', + '{message}' => $data['message'] !== '' ? $data['message'] : '-', + '{submitted_at}' => $this->formatDateTimeForMail($createdAt, $isoCode), + '{admin_link}' => $adminLink, + '{shop_name}' => $shopName, + ]; + + try { + return Mail::Send( + $idLang, + 'withdrawal_notification', + $this->getNotificationSubject($data['order_reference'], $isoCode), + $templateVars, + $toEmail, + null, + $shopEmail, + $shopName, + null, + null, + _PS_MODULE_DIR_ . $this->name . '/mails/', + false, + $idShop + ); + } catch (\Exception $e) { + return false; + } + } + + public function purgeOldRecords($months) + { + $months = (int) $months; + if ($months <= 0) { + return 0; + } + + $sql = 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . pSQL(self::TABLE_REQUEST) . '` + WHERE `created_at` < DATE_SUB(NOW(), INTERVAL ' . $months . ' MONTH)'; + $count = (int) Db::getInstance()->getValue($sql); + + if ($count > 0) { + Db::getInstance()->execute( + 'DELETE FROM `' . _DB_PREFIX_ . pSQL(self::TABLE_REQUEST) . '` + WHERE `created_at` < DATE_SUB(NOW(), INTERVAL ' . $months . ' MONTH)' + ); + } + + return $count; + } + + public function formatDateTimeForMail($dateTime, $isoCode = 'de') + { + $timestamp = strtotime((string) $dateTime); + if (!$timestamp) { + $timestamp = time(); + } + + if ($isoCode === 'de') { + return date('d.m.Y H:i:s', $timestamp) . ' Uhr'; + } + + return date('Y-m-d H:i:s', $timestamp); + } + + private function getLangIsoCode($idLang) + { + $iso = Language::getIsoById((int) $idLang); + return $iso ?: 'de'; + } + + private function getScopeLabel($scope, $isoCode) + { + $labels = [ + 'de' => ['partial' => 'Teil der Bestellung', 'full' => 'gesamte Bestellung'], + 'en' => ['partial' => 'part of the order', 'full' => 'full order'], + ]; + $map = isset($labels[$isoCode]) ? $labels[$isoCode] : $labels['de']; + + return $scope === 'partial' ? $map['partial'] : $map['full']; + } + + private function getConfirmationSubject($isoCode) + { + $subjects = [ + 'de' => 'Eingangsbestätigung Ihres Widerrufs', + 'en' => 'Confirmation of receipt of your withdrawal', + ]; + + return isset($subjects[$isoCode]) ? $subjects[$isoCode] : $subjects['de']; + } + + private function getNotificationSubject($orderReference, $isoCode) + { + if ($isoCode === 'en') { + return 'New withdrawal: order ' . $orderReference; + } + + return 'Neuer Widerruf: Bestellung ' . $orderReference; + } +} diff --git a/sql/index.php b/sql/index.php new file mode 100644 index 0000000..0d10a9e --- /dev/null +++ b/sql/index.php @@ -0,0 +1,2 @@ + +

{l s='Installation notes' mod='simple_withdrawalbutton'}

+

+ {l s='Frontend withdrawal page:' mod='simple_withdrawalbutton'} + {$withdrawal_link|escape:'html':'UTF-8'} +

+

+ {l s='Back office requests:' mod='simple_withdrawalbutton'} + {l s='Open withdrawal requests' mod='simple_withdrawalbutton'} +

+

+ {l s='The module keeps existing withdrawal records when it is uninstalled. Delete the database table manually only after checking your retention obligations.' mod='simple_withdrawalbutton'} +

+ diff --git a/views/templates/admin/index.php b/views/templates/admin/index.php new file mode 100644 index 0000000..0d10a9e --- /dev/null +++ b/views/templates/admin/index.php @@ -0,0 +1,2 @@ + +
+ + {l s='Withdrawal request' mod='simple_withdrawalbutton'} #{$request.id_withdrawal_request|intval} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{l s='Received' mod='simple_withdrawalbutton'}{$request.created_at|escape:'html':'UTF-8'}
{l s='Confirmation sent' mod='simple_withdrawalbutton'}{if $request.confirmation_sent_at}{$request.confirmation_sent_at|escape:'html':'UTF-8'}{else}-{/if}
{l s='Order reference' mod='simple_withdrawalbutton'} + {$request.order_reference|escape:'html':'UTF-8'} + {if $order_link != ''} +
{l s='Open order' mod='simple_withdrawalbutton'} + {/if} +
{l s='Name' mod='simple_withdrawalbutton'}{$request.customer_name|escape:'html':'UTF-8'}
{l s='Email' mod='simple_withdrawalbutton'}{$request.customer_email|escape:'html':'UTF-8'}
{l s='Scope' mod='simple_withdrawalbutton'} + {if $request.withdrawal_scope == 'partial'} + {l s='Partial withdrawal' mod='simple_withdrawalbutton'} + {else} + {l s='Full order' mod='simple_withdrawalbutton'} + {/if} +
{l s='Affected items / quantities' mod='simple_withdrawalbutton'}{if $request.withdrawal_items_text}{$request.withdrawal_items_text|escape:'html':'UTF-8'|nl2br nofilter}{else}-{/if}
{l s='Message' mod='simple_withdrawalbutton'}{if $request.message}{$request.message|escape:'html':'UTF-8'|nl2br nofilter}{else}-{/if}
+
+ +
+
+ +
+ + +
+ +
+
+
+ + + diff --git a/views/templates/front/confirm.tpl b/views/templates/front/confirm.tpl new file mode 100644 index 0000000..45be940 --- /dev/null +++ b/views/templates/front/confirm.tpl @@ -0,0 +1,70 @@ +
+ + +
+ {if $errors_list|@count > 0} +
+
    + {foreach from=$errors_list item=error} +
  • {$error|escape:'html':'UTF-8'}
  • + {/foreach} +
+
+ {/if} + +

{l s='Bitte prüfen Sie Ihre Angaben. Wenn die Angaben korrekt sind, können Sie Ihren Widerruf jetzt absenden.' mod='simple_withdrawalbutton'}

+ +
+
{l s='Name' mod='simple_withdrawalbutton'}
+
{$form_data.customer_name|escape:'html':'UTF-8'}
+ +
{l s='E-Mail-Adresse' mod='simple_withdrawalbutton'}
+
{$form_data.customer_email|escape:'html':'UTF-8'}
+ +
{l s='Bestellnummer / Bestellreferenz' mod='simple_withdrawalbutton'}
+
{$form_data.order_reference|escape:'html':'UTF-8'}
+ +
{l s='Widerruf betrifft' mod='simple_withdrawalbutton'}
+
+ {if $form_data.withdrawal_scope == 'partial'} + {l s='einen Teil der Bestellung' mod='simple_withdrawalbutton'} + {else} + {l s='die gesamte Bestellung' mod='simple_withdrawalbutton'} + {/if} +
+ + {if $form_data.withdrawal_scope == 'partial'} +
{l s='Betroffene Artikel / Mengen' mod='simple_withdrawalbutton'}
+
{$form_data.withdrawal_items_text|escape:'html':'UTF-8'|nl2br nofilter}
+ {/if} + + {if $form_data.message != ''} +
{l s='Nachricht / Bemerkung' mod='simple_withdrawalbutton'}
+
{$form_data.message|escape:'html':'UTF-8'|nl2br nofilter}
+ {/if} +
+ +
+ + + + + + + + + + + +
+
+
diff --git a/views/templates/front/form.tpl b/views/templates/front/form.tpl new file mode 100644 index 0000000..f86d11f --- /dev/null +++ b/views/templates/front/form.tpl @@ -0,0 +1,127 @@ +
+ + +
+

+ {l s='Sie können hier den Vertrag zu einer Bestellung vollständig oder teilweise widerrufen. Bitte geben Sie die Bestellnummer und bei einem Teilwiderruf die betroffenen Artikel und Mengen an.' mod='simple_withdrawalbutton'} +

+ + {if isset($revocation_url) && $revocation_url != ''} +

+ + {l s='Unsere Widerrufsbelehrung (14-Tage-Frist)' mod='simple_withdrawalbutton'} + +

+ {/if} + + {if $errors_list|@count > 0} +
+
    + {foreach from=$errors_list item=error} +
  • {$error|escape:'html':'UTF-8'}
  • + {/foreach} +
+
+ {/if} + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {l s='Widerruf betrifft' mod='simple_withdrawalbutton'} + + +
+ +
+ +
+ + + + {l s='Nur bei einem Teilwiderruf erforderlich.' mod='simple_withdrawalbutton'} + +
+ +
+ + +
+ +

+ {l s='Pflichtfelder sind mit * gekennzeichnet. Ein Widerrufsgrund ist nicht erforderlich.' mod='simple_withdrawalbutton'} +

+ +

+ {l s='Ihre Angaben (Name, E-Mail-Adresse) werden zur Bearbeitung Ihrer Widerrufserklärung verarbeitet. Rechtsgrundlage: Art. 6 Abs. 1 lit. b und c DSGVO.' mod='simple_withdrawalbutton'} + {if isset($privacy_url) && $privacy_url != ''} + + {l s='Datenschutzerklärung' mod='simple_withdrawalbutton'} + + {/if} +

+ + +
+
+
+ + diff --git a/views/templates/front/index.php b/views/templates/front/index.php new file mode 100644 index 0000000..0d10a9e --- /dev/null +++ b/views/templates/front/index.php @@ -0,0 +1,2 @@ + + + +
+ {if isset($success_data.mail_ok) && $success_data.mail_ok} +
+ {l s='Ihr Widerruf wurde übermittelt. Eine Eingangsbestätigung wurde an die angegebene E-Mail-Adresse gesendet.' mod='simple_withdrawalbutton'} +
+ {else} +
+ {l s='Ihr Widerruf wurde gespeichert, aber die automatische Eingangsbestätigung konnte möglicherweise nicht versendet werden. Bitte kontaktieren Sie uns zusätzlich per E-Mail, falls Sie keine Bestätigung erhalten.' mod='simple_withdrawalbutton'} +
+ {/if} + +
+ {if isset($success_data.created_at)} +
{l s='Eingang:' mod='simple_withdrawalbutton'}
+
{$success_data.created_at|escape:'html':'UTF-8'}
+ {/if} + + {if isset($success_data.customer_email)} +
{l s='E-Mail-Adresse:' mod='simple_withdrawalbutton'}
+
{$success_data.customer_email|escape:'html':'UTF-8'}
+ {/if} + + {if isset($success_data.order_reference) && $success_data.order_reference != ''} +
{l s='Bestellnummer / Bestellreferenz' mod='simple_withdrawalbutton'}
+
{$success_data.order_reference|escape:'html':'UTF-8'}
+ {/if} + + {if isset($success_data.withdrawal_scope)} +
{l s='Widerruf betrifft' mod='simple_withdrawalbutton'}
+
+ {if $success_data.withdrawal_scope == 'partial'} + {l s='einen Teil der Bestellung' mod='simple_withdrawalbutton'} + {else} + {l s='die gesamte Bestellung' mod='simple_withdrawalbutton'} + {/if} +
+ {/if} + + {if isset($success_data.withdrawal_items_text) && $success_data.withdrawal_items_text != ''} +
{l s='Betroffene Artikel / Mengen' mod='simple_withdrawalbutton'}
+
{$success_data.withdrawal_items_text|escape:'html':'UTF-8'|nl2br nofilter}
+ {/if} + + {if isset($success_data.message) && $success_data.message != ''} +
{l s='Nachricht / Bemerkung' mod='simple_withdrawalbutton'}
+
{$success_data.message|escape:'html':'UTF-8'|nl2br nofilter}
+ {/if} +
+ +

+ {l s='Diese Bestätigung betrifft nur den Eingang Ihrer Widerrufserklärung. Die weitere Bearbeitung und Prüfung erfolgt separat.' mod='simple_withdrawalbutton'} +

+
+ diff --git a/views/templates/hook/customer_account.tpl b/views/templates/hook/customer_account.tpl new file mode 100644 index 0000000..c34f886 --- /dev/null +++ b/views/templates/hook/customer_account.tpl @@ -0,0 +1,6 @@ + + + undo + {l s='Vertrag widerrufen' mod='simple_withdrawalbutton'} + + diff --git a/views/templates/hook/footer.tpl b/views/templates/hook/footer.tpl new file mode 100644 index 0000000..69b71ee --- /dev/null +++ b/views/templates/hook/footer.tpl @@ -0,0 +1,5 @@ + diff --git a/views/templates/hook/index.php b/views/templates/hook/index.php new file mode 100644 index 0000000..0d10a9e --- /dev/null +++ b/views/templates/hook/index.php @@ -0,0 +1,2 @@ +