Add simple_withdrawalbutton PrestaShop module

This commit is contained in:
Arne Weiss
2026-06-01 08:08:31 +02:00
commit 0691fa7f22
33 changed files with 1679 additions and 0 deletions
+59
View File
@@ -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}/`
+55
View File
@@ -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.
@@ -0,0 +1,153 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
class AdminSimpleWithdrawalController extends ModuleAdminController
{
public function __construct()
{
$this->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 '<span class="label label-info">' . $this->l('Partial') . '</span>';
}
return '<span class="label label-default">' . $this->l('Full order') . '</span>';
}
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 '<span class="label ' . $label['class'] . '">' . Tools::safeOutput($label['text']) . '</span>';
}
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'
);
}
}
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+223
View File
@@ -0,0 +1,223 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
class Simple_withdrawalbuttonWithdrawModuleFrontController extends ModuleFrontController
{
public $ssl = true;
public $auth = false;
public $guestAllowed = true;
/** @var string */
private $currentView = 'form';
/** @var array */
private $formData = [];
/** @var array */
private $errorsList = [];
/** @var array */
private $successData = [];
public function initContent()
{
parent::initContent();
$this->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')) !== '';
}
}
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+4
View File
@@ -0,0 +1,4 @@
<?php
/**
* Silence is golden.
*/
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Eingangsbestätigung Ihres Widerrufs</title>
</head>
<body>
<p>Guten Tag {customer_name},</p>
<p>wir bestätigen den Eingang Ihrer Widerrufserklärung.</p>
<p>
<strong>Eingang des Widerrufs:</strong><br>
{submitted_at}
</p>
<p>
<strong>Bestellnummer / Bestellreferenz:</strong><br>
{order_reference}
</p>
<p>
<strong>Widerruf betrifft:</strong><br>
{scope_label}
</p>
<p>
<strong>Betroffene Artikel / Mengen:</strong><br>
{withdrawal_items_text}
</p>
<p>
<strong>Ihre Nachricht / Bemerkung:</strong><br>
{message}
</p>
<p>
Diese Nachricht bestätigt nur den Eingang Ihrer Widerrufserklärung. Die weitere Bearbeitung und Prüfung erfolgt separat.
</p>
<p>Mit freundlichen Grüßen<br>{shop_name}</p>
</body>
</html>
+23
View File
@@ -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}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Neuer Widerruf</title>
</head>
<body>
<p>Ein neuer Widerruf ist eingegangen.</p>
<p>
<strong>ID:</strong> {id_withdrawal_request}<br>
<strong>Eingang:</strong> {submitted_at}<br>
<strong>Bestellnummer / Bestellreferenz:</strong> {order_reference}<br>
<strong>Name:</strong> {customer_name}<br>
<strong>E-Mail:</strong> {customer_email}<br>
<strong>Widerruf betrifft:</strong> {scope_label}
</p>
<p>
<strong>Betroffene Artikel / Mengen:</strong><br>
{withdrawal_items_text}
</p>
<p>
<strong>Nachricht / Bemerkung:</strong><br>
{message}
</p>
<p>
Backoffice: <a href="{admin_link}">{admin_link}</a>
</p>
</body>
</html>
+17
View File
@@ -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}
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Confirmation of receipt of your withdrawal</title>
</head>
<body>
<p>Hello {customer_name},</p>
<p>We confirm receipt of your withdrawal declaration.</p>
<p><strong>Received:</strong><br>{submitted_at}</p>
<p><strong>Order number / order reference:</strong><br>{order_reference}</p>
<p><strong>Withdrawal concerns:</strong><br>{scope_label}</p>
<p><strong>Affected items / quantities:</strong><br>{withdrawal_items_text}</p>
<p><strong>Your message / note:</strong><br>{message}</p>
<p>This message only confirms receipt of your withdrawal declaration. Further processing and review will follow separately.</p>
<p>Kind regards<br>{shop_name}</p>
</body>
</html>
+23
View File
@@ -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}
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>New withdrawal declaration</title>
</head>
<body>
<p>A new withdrawal declaration has been received.</p>
<p>
<strong>ID:</strong> {id_withdrawal_request}<br>
<strong>Received:</strong> {submitted_at}<br>
<strong>Order number / order reference:</strong> {order_reference}<br>
<strong>Name:</strong> {customer_name}<br>
<strong>Email:</strong> {customer_email}<br>
<strong>Withdrawal concerns:</strong> {scope_label}
</p>
<p><strong>Affected items / quantities:</strong><br>{withdrawal_items_text}</p>
<p><strong>Message / note:</strong><br>{message}</p>
<p>Back office: <a href="{admin_link}">{admin_link}</a></p>
</body>
</html>
+17
View File
@@ -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}
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+597
View File
@@ -0,0 +1,597 @@
<?php
/**
* Minimal electronic withdrawal button for PrestaShop.
*
* This module implements a minimal two-step withdrawal declaration flow:
* - public footer link "Vertrag widerrufen"
* - public form for full or partial withdrawal
* - confirmation step with button "Widerruf bestätigen"
* - database record
* - email confirmation to customer and notification to shop owner
*/
if (!defined('_PS_VERSION_')) {
exit;
}
class Simple_withdrawalbutton extends Module
{
public const TABLE_REQUEST = 'simple_withdrawal_request';
public const CONF_SHOP_EMAIL = 'SIMPLE_WITHDRAWAL_SHOP_EMAIL';
public const CONF_RATE_LIMIT = 'SIMPLE_WITHDRAWAL_RATE_LIMIT';
public const CONF_PRIVACY_URL = 'SIMPLE_WITHDRAWAL_PRIVACY_URL';
public const CONF_REVOCATION_URL = 'SIMPLE_WITHDRAWAL_REVOCATION_URL';
public const CONF_RETENTION_MONTHS = 'SIMPLE_WITHDRAWAL_RETENTION_MONTHS';
public function __construct()
{
$this->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;
}
}
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+14
View File
@@ -0,0 +1,14 @@
<div class="panel">
<h3><i class="icon-info-circle"></i> {l s='Installation notes' mod='simple_withdrawalbutton'}</h3>
<p>
{l s='Frontend withdrawal page:' mod='simple_withdrawalbutton'}
<a href="{$withdrawal_link|escape:'html':'UTF-8'}" target="_blank" rel="noopener noreferrer">{$withdrawal_link|escape:'html':'UTF-8'}</a>
</p>
<p>
{l s='Back office requests:' mod='simple_withdrawalbutton'}
<a href="{$admin_link|escape:'html':'UTF-8'}">{l s='Open withdrawal requests' mod='simple_withdrawalbutton'}</a>
</p>
<p class="help-block">
{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'}
</p>
</div>
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+81
View File
@@ -0,0 +1,81 @@
<div class="panel">
<div class="panel-heading">
<i class="icon-undo"></i>
{l s='Withdrawal request' mod='simple_withdrawalbutton'} #{$request.id_withdrawal_request|intval}
</div>
<div class="row">
<div class="col-lg-8">
<table class="table">
<tbody>
<tr>
<th>{l s='Received' mod='simple_withdrawalbutton'}</th>
<td>{$request.created_at|escape:'html':'UTF-8'}</td>
</tr>
<tr>
<th>{l s='Confirmation sent' mod='simple_withdrawalbutton'}</th>
<td>{if $request.confirmation_sent_at}{$request.confirmation_sent_at|escape:'html':'UTF-8'}{else}-{/if}</td>
</tr>
<tr>
<th>{l s='Order reference' mod='simple_withdrawalbutton'}</th>
<td>
{$request.order_reference|escape:'html':'UTF-8'}
{if $order_link != ''}
<br><a href="{$order_link|escape:'html':'UTF-8'}">{l s='Open order' mod='simple_withdrawalbutton'}</a>
{/if}
</td>
</tr>
<tr>
<th>{l s='Name' mod='simple_withdrawalbutton'}</th>
<td>{$request.customer_name|escape:'html':'UTF-8'}</td>
</tr>
<tr>
<th>{l s='Email' mod='simple_withdrawalbutton'}</th>
<td><a href="mailto:{$request.customer_email|escape:'html':'UTF-8'}">{$request.customer_email|escape:'html':'UTF-8'}</a></td>
</tr>
<tr>
<th>{l s='Scope' mod='simple_withdrawalbutton'}</th>
<td>
{if $request.withdrawal_scope == 'partial'}
{l s='Partial withdrawal' mod='simple_withdrawalbutton'}
{else}
{l s='Full order' mod='simple_withdrawalbutton'}
{/if}
</td>
</tr>
<tr>
<th>{l s='Affected items / quantities' mod='simple_withdrawalbutton'}</th>
<td>{if $request.withdrawal_items_text}{$request.withdrawal_items_text|escape:'html':'UTF-8'|nl2br nofilter}{else}-{/if}</td>
</tr>
<tr>
<th>{l s='Message' mod='simple_withdrawalbutton'}</th>
<td>{if $request.message}{$request.message|escape:'html':'UTF-8'|nl2br nofilter}{else}-{/if}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-lg-4">
<form method="post" action="{$current_index|escape:'html':'UTF-8'}&amp;token={$token|escape:'html':'UTF-8'}">
<input type="hidden" name="{$identifier|escape:'html':'UTF-8'}" value="{$request.id_withdrawal_request|intval}">
<div class="form-group">
<label for="cyp_withdrawal_status">{l s='Status' mod='simple_withdrawalbutton'}</label>
<select name="status" id="cyp_withdrawal_status" class="form-control">
<option value="new" {if $request.status == 'new'}selected{/if}>{l s='New' mod='simple_withdrawalbutton'}</option>
<option value="processing" {if $request.status == 'processing'}selected{/if}>{l s='Processing' mod='simple_withdrawalbutton'}</option>
<option value="closed" {if $request.status == 'closed'}selected{/if}>{l s='Closed' mod='simple_withdrawalbutton'}</option>
</select>
</div>
<button type="submit" name="submitSimpleWithdrawalStatus" value="1" class="btn btn-primary">
{l s='Update status' mod='simple_withdrawalbutton'}
</button>
</form>
</div>
</div>
<div class="panel-footer">
<a href="{$back_link|escape:'html':'UTF-8'}" class="btn btn-default">
<i class="process-icon-back"></i> {l s='Back to list' mod='simple_withdrawalbutton'}
</a>
</div>
</div>
+70
View File
@@ -0,0 +1,70 @@
<section class="cyp-withdrawal-page">
<header class="page-header">
<h1>{l s='Widerruf bestätigen' mod='simple_withdrawalbutton'}</h1>
</header>
<div class="card card-block">
{if $errors_list|@count > 0}
<div class="alert alert-danger">
<ul>
{foreach from=$errors_list item=error}
<li>{$error|escape:'html':'UTF-8'}</li>
{/foreach}
</ul>
</div>
{/if}
<p>{l s='Bitte prüfen Sie Ihre Angaben. Wenn die Angaben korrekt sind, können Sie Ihren Widerruf jetzt absenden.' mod='simple_withdrawalbutton'}</p>
<dl class="row">
<dt class="col-sm-4">{l s='Name' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$form_data.customer_name|escape:'html':'UTF-8'}</dd>
<dt class="col-sm-4">{l s='E-Mail-Adresse' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$form_data.customer_email|escape:'html':'UTF-8'}</dd>
<dt class="col-sm-4">{l s='Bestellnummer / Bestellreferenz' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$form_data.order_reference|escape:'html':'UTF-8'}</dd>
<dt class="col-sm-4">{l s='Widerruf betrifft' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">
{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}
</dd>
{if $form_data.withdrawal_scope == 'partial'}
<dt class="col-sm-4">{l s='Betroffene Artikel / Mengen' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$form_data.withdrawal_items_text|escape:'html':'UTF-8'|nl2br nofilter}</dd>
{/if}
{if $form_data.message != ''}
<dt class="col-sm-4">{l s='Nachricht / Bemerkung' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$form_data.message|escape:'html':'UTF-8'|nl2br nofilter}</dd>
{/if}
</dl>
<form method="post" action="{$action_url|escape:'html':'UTF-8'}">
<input type="hidden" name="csrf_token" value="{$csrf_token|escape:'html':'UTF-8'}">
<input type="hidden" name="customer_name" value="{$form_data.customer_name|escape:'html':'UTF-8'}">
<input type="hidden" name="customer_email" value="{$form_data.customer_email|escape:'html':'UTF-8'}">
<input type="hidden" name="order_reference" value="{$form_data.order_reference|escape:'html':'UTF-8'}">
<input type="hidden" name="withdrawal_scope" value="{$form_data.withdrawal_scope|escape:'html':'UTF-8'}">
<input type="hidden" name="withdrawal_items_text" value="{$form_data.withdrawal_items_text|escape:'html':'UTF-8'}">
<input type="hidden" name="message" value="{$form_data.message|escape:'html':'UTF-8'}">
<div style="position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden;" aria-hidden="true">
<label for="cyp_withdrawal_hp_confirm">Website</label>
<input type="text" id="cyp_withdrawal_hp_confirm" name="cyp_hp_v1" value="" tabindex="-1" autocomplete="off">
</div>
<button type="submit" name="submit_withdrawal_confirm" value="1" class="btn btn-primary">
{l s='Widerruf bestätigen' mod='simple_withdrawalbutton'}
</button>
<button type="submit" name="submit_withdrawal_back" value="1" class="btn btn-secondary" formaction="{$action_url|escape:'html':'UTF-8'}">
{l s='Angaben ändern' mod='simple_withdrawalbutton'}
</button>
</form>
</div>
</section>
+127
View File
@@ -0,0 +1,127 @@
<section class="cyp-withdrawal-page">
<header class="page-header">
<h1>{l s='Vertrag widerrufen' mod='simple_withdrawalbutton'}</h1>
</header>
<div class="card card-block">
<p>
{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'}
</p>
{if isset($revocation_url) && $revocation_url != ''}
<p>
<a href="{$revocation_url|escape:'html':'UTF-8'}" target="_blank" rel="noopener">
{l s='Unsere Widerrufsbelehrung (14-Tage-Frist)' mod='simple_withdrawalbutton'}
</a>
</p>
{/if}
{if $errors_list|@count > 0}
<div class="alert alert-danger">
<ul>
{foreach from=$errors_list item=error}
<li>{$error|escape:'html':'UTF-8'}</li>
{/foreach}
</ul>
</div>
{/if}
<form method="post" action="{$action_url|escape:'html':'UTF-8'}" class="cyp-withdrawal-form" novalidate>
<input type="hidden" name="csrf_token" value="{$csrf_token|escape:'html':'UTF-8'}">
<div style="position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden;" aria-hidden="true">
<label for="cyp_withdrawal_hp">Website</label>
<input type="text" id="cyp_withdrawal_hp" name="cyp_hp_v1" value="" tabindex="-1" autocomplete="off">
</div>
<div class="form-group">
<label for="cyp_withdrawal_customer_name" class="form-control-label">
{l s='Name' mod='simple_withdrawalbutton'} <span aria-hidden="true">*</span>
</label>
<input class="form-control" type="text" id="cyp_withdrawal_customer_name" name="customer_name" maxlength="255" required value="{$form_data.customer_name|default:''|escape:'html':'UTF-8'}">
</div>
<div class="form-group">
<label for="cyp_withdrawal_customer_email" class="form-control-label">
{l s='E-Mail-Adresse für die Eingangsbestätigung' mod='simple_withdrawalbutton'} <span aria-hidden="true">*</span>
</label>
<input class="form-control" type="email" id="cyp_withdrawal_customer_email" name="customer_email" maxlength="255" required value="{$form_data.customer_email|default:''|escape:'html':'UTF-8'}">
</div>
<div class="form-group">
<label for="cyp_withdrawal_order_reference" class="form-control-label">
{l s='Bestellnummer / Bestellreferenz' mod='simple_withdrawalbutton'} <span aria-hidden="true">*</span>
</label>
<input class="form-control" type="text" id="cyp_withdrawal_order_reference" name="order_reference" maxlength="64" required value="{$form_data.order_reference|default:''|escape:'html':'UTF-8'}">
</div>
<fieldset class="form-group">
<legend class="form-control-label">
{l s='Widerruf betrifft' mod='simple_withdrawalbutton'} <span aria-hidden="true">*</span>
</legend>
<label class="custom-radio" for="cyp_withdrawal_scope_full">
<input type="radio" id="cyp_withdrawal_scope_full" name="withdrawal_scope" value="full" {if !isset($form_data.withdrawal_scope) || $form_data.withdrawal_scope != 'partial'}checked{/if}>
<span></span>
{l s='die gesamte Bestellung' mod='simple_withdrawalbutton'}
</label>
<br>
<label class="custom-radio" for="cyp_withdrawal_scope_partial">
<input type="radio" id="cyp_withdrawal_scope_partial" name="withdrawal_scope" value="partial" {if isset($form_data.withdrawal_scope) && $form_data.withdrawal_scope == 'partial'}checked{/if}>
<span></span>
{l s='einen Teil der Bestellung' mod='simple_withdrawalbutton'}
</label>
</fieldset>
<div class="form-group" id="cyp-withdrawal-items-group">
<label for="cyp_withdrawal_items_text" class="form-control-label">
{l s='Welche Artikel / Mengen möchten Sie widerrufen?' mod='simple_withdrawalbutton'}
</label>
<textarea class="form-control" id="cyp_withdrawal_items_text" name="withdrawal_items_text" rows="4" maxlength="5000" placeholder="{l s='Beispiel: 1x Tomizu 720 ml, 2x Magokoro 300 ml' mod='simple_withdrawalbutton'}">{$form_data.withdrawal_items_text|default:''|escape:'html':'UTF-8'}</textarea>
<small class="form-text text-muted">
{l s='Nur bei einem Teilwiderruf erforderlich.' mod='simple_withdrawalbutton'}
</small>
</div>
<div class="form-group">
<label for="cyp_withdrawal_message" class="form-control-label">
{l s='Nachricht / Bemerkung optional' mod='simple_withdrawalbutton'}
</label>
<textarea class="form-control" id="cyp_withdrawal_message" name="message" rows="4" maxlength="5000">{$form_data.message|default:''|escape:'html':'UTF-8'}</textarea>
</div>
<p class="small text-muted">
{l s='Pflichtfelder sind mit * gekennzeichnet. Ein Widerrufsgrund ist nicht erforderlich.' mod='simple_withdrawalbutton'}
</p>
<p class="small text-muted">
{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 != ''}
<a href="{$privacy_url|escape:'html':'UTF-8'}" target="_blank" rel="noopener">
{l s='Datenschutzerklärung' mod='simple_withdrawalbutton'}
</a>
{/if}
</p>
<button type="submit" name="submit_withdrawal_prepare" value="1" class="btn btn-primary">
{l s='Angaben prüfen' mod='simple_withdrawalbutton'}
</button>
</form>
</div>
</section>
<script>
(function () {
var partial = document.getElementById('cyp_withdrawal_scope_partial');
var full = document.getElementById('cyp_withdrawal_scope_full');
var group = document.getElementById('cyp-withdrawal-items-group');
var textarea = document.getElementById('cyp_withdrawal_items_text');
function update() {
if (!group || !partial || !textarea) return;
var show = partial.checked;
group.style.display = show ? '' : 'none';
textarea.required = show;
}
if (partial) partial.addEventListener('change', update);
if (full) full.addEventListener('change', update);
update();
})();
</script>
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+59
View File
@@ -0,0 +1,59 @@
<section class="cyp-withdrawal-page">
<header class="page-header">
<h1>{l s='Widerruf übermittelt' mod='simple_withdrawalbutton'}</h1>
</header>
<div class="card card-block">
{if isset($success_data.mail_ok) && $success_data.mail_ok}
<div class="alert alert-success">
{l s='Ihr Widerruf wurde übermittelt. Eine Eingangsbestätigung wurde an die angegebene E-Mail-Adresse gesendet.' mod='simple_withdrawalbutton'}
</div>
{else}
<div class="alert alert-warning">
{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'}
</div>
{/if}
<dl class="row">
{if isset($success_data.created_at)}
<dt class="col-sm-4">{l s='Eingang:' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$success_data.created_at|escape:'html':'UTF-8'}</dd>
{/if}
{if isset($success_data.customer_email)}
<dt class="col-sm-4">{l s='E-Mail-Adresse:' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$success_data.customer_email|escape:'html':'UTF-8'}</dd>
{/if}
{if isset($success_data.order_reference) && $success_data.order_reference != ''}
<dt class="col-sm-4">{l s='Bestellnummer / Bestellreferenz' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$success_data.order_reference|escape:'html':'UTF-8'}</dd>
{/if}
{if isset($success_data.withdrawal_scope)}
<dt class="col-sm-4">{l s='Widerruf betrifft' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">
{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}
</dd>
{/if}
{if isset($success_data.withdrawal_items_text) && $success_data.withdrawal_items_text != ''}
<dt class="col-sm-4">{l s='Betroffene Artikel / Mengen' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$success_data.withdrawal_items_text|escape:'html':'UTF-8'|nl2br nofilter}</dd>
{/if}
{if isset($success_data.message) && $success_data.message != ''}
<dt class="col-sm-4">{l s='Nachricht / Bemerkung' mod='simple_withdrawalbutton'}</dt>
<dd class="col-sm-8">{$success_data.message|escape:'html':'UTF-8'|nl2br nofilter}</dd>
{/if}
</dl>
<p class="small text-muted">
{l s='Diese Bestätigung betrifft nur den Eingang Ihrer Widerrufserklärung. Die weitere Bearbeitung und Prüfung erfolgt separat.' mod='simple_withdrawalbutton'}
</p>
</div>
</section>
@@ -0,0 +1,6 @@
<a class="col-lg-4 col-md-6 col-sm-6 col-xs-12" id="withdrawal-link" href="{$withdrawal_link|escape:'html':'UTF-8'}" rel="nofollow">
<span class="link-item">
<i class="material-icons">undo</i>
{l s='Vertrag widerrufen' mod='simple_withdrawalbutton'}
</span>
</a>
+5
View File
@@ -0,0 +1,5 @@
<div class="cyp-withdrawal-footer" style="margin-top: 1rem;">
<a href="{$withdrawal_link|escape:'html':'UTF-8'}" class="cyp-withdrawal-link" rel="nofollow">
{l s='Vertrag widerrufen' mod='simple_withdrawalbutton'}
</a>
</div>
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */
+2
View File
@@ -0,0 +1,2 @@
<?php
/** Silence is golden. */