diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff0608a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+
+vendor/
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..fd1a99a
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,29 @@
+{
+ "name": "dewebsmid/ws_orderreference",
+ "description": "Adds an optional order reference field to the shopping cart summary.",
+ "authors": [
+ {
+ "name": "Isabelle Oving-Anno",
+ "email": "isabelle@dewebsmid.nl"
+ }
+ ],
+ "require": {
+ "php": ">=8.2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Module\\WsOrderreference\\": "src/"
+ },
+ "classmap": [
+ "ws_orderreference.php"
+ ],
+ "exclude-from-classmap": []
+ },
+ "config": {
+ "preferred-install": "dist",
+ "prepend-autoloader": false
+ },
+ "type": "prestashop-module",
+ "author": "Isabelle Oving-Anno",
+ "license": "AFL-3.0"
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..b68d89f
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,20 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "14a8801fe935c090041cfc25afe6829f",
+ "packages": [],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.2.0"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.6.0"
+}
diff --git a/config/services.yml b/config/services.yml
new file mode 100644
index 0000000..d3c90e7
--- /dev/null
+++ b/config/services.yml
@@ -0,0 +1,18 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+
+ # Repository - this makes it available via dependency injection
+ Module\WsOrderreference\Repository\OrderReferenceRepository:
+ public: true
+ factory: ["@doctrine.orm.default_entity_manager", getRepository]
+ arguments:
+ - Module\WsOrderreference\Entity\OrderReference
+
+ # Database installer
+ Module\WsOrderreference\Database\ReferenceInstaller:
+ public: true
+ arguments:
+ - "@doctrine.dbal.default_connection"
+ - "%database_prefix%"
diff --git a/config_nl.xml b/config_nl.xml
new file mode 100644
index 0000000..2e9e529
--- /dev/null
+++ b/config_nl.xml
@@ -0,0 +1,11 @@
+
+
+ ws_orderreference
+
+
+
+
+
+ 0
+ 0
+
\ No newline at end of file
diff --git a/controllers/front/ajax.php b/controllers/front/ajax.php
new file mode 100644
index 0000000..0d030b3
--- /dev/null
+++ b/controllers/front/ajax.php
@@ -0,0 +1,63 @@
+context->cart->id;
+
+ if (!$idCart) {
+ $this->ajaxRender(json_encode([
+ 'success' => false,
+ 'message' => $this->module->l('No cart found.', 'ajax')
+ ]));
+ return;
+ }
+
+ if (!preg_match('/^[a-zA-Z0-9_\- ]+$/', $orderReference)) {
+ $this->ajaxRender(json_encode([
+ 'success' => false,
+ 'message' => $this->module->l('Please enter a valid order reference. Characters allowed: a-z, 0-9, -, _ and space', 'ajax')
+ ]));
+ return;
+ }
+
+ try {
+ /** @var \Doctrine\ORM\EntityManagerInterface $entityManager */
+ $entityManager = $this->get('doctrine.orm.default_entity_manager');
+
+ /** @var OrderReferenceRepository $repository */
+ $repository = $entityManager->getRepository(OrderReference::class);
+
+ $orderRef = $repository->findByCartId($idCart);
+
+ if (!$orderRef) {
+ $orderRef = new OrderReference();
+ $orderRef->setIdCart($idCart);
+ }
+
+ $orderRef->setOrderReference($orderReference);
+
+ $entityManager->persist($orderRef);
+ $entityManager->flush();
+
+ $this->ajaxRender(json_encode([
+ 'success' => true,
+ 'message' => $this->module->l('Order reference received!', 'ajax')
+ ]));
+ } catch (Exception $e) {
+ $this->ajaxRender(json_encode([
+ 'success' => false,
+ 'message' => $this->module->l('Could not save order reference.', 'ajax')
+ ]));
+ }
+ }
+}
\ No newline at end of file
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..dedd77e
--- /dev/null
+++ b/index.php
@@ -0,0 +1,35 @@
+
+ * @copyright Since 2007 PrestaShop SA and Contributors
+ * @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0)
+ */
+
+header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
+header("Last-Modified: ".gmdate("D, d M Y H:i:s")." GMT");
+
+header("Cache-Control: no-store, no-cache, must-revalidate");
+header("Cache-Control: post-check=0, pre-check=0", false);
+header("Pragma: no-cache");
+
+header("Location: ../");
+exit;
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..d0df3a7
Binary files /dev/null and b/logo.png differ
diff --git a/src/Database/ReferenceInstaller.php b/src/Database/ReferenceInstaller.php
new file mode 100644
index 0000000..2c33c4f
--- /dev/null
+++ b/src/Database/ReferenceInstaller.php
@@ -0,0 +1,82 @@
+
+ * @copyright 2026 De Websmid
+ * @license De Websmid
+ *
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Academic Free License 3.0 (AFL-3.0).
+ * It is also available through the world-wide-web at this URL: https://opensource.org/licenses/AFL-3.0
+ */
+
+declare(strict_types=1);
+
+namespace Module\WsOrderreference\Database;
+
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Exception as DBALException;
+
+class ReferenceInstaller
+{
+ private Connection $connection;
+ private string $dbPrefix;
+
+ public function __construct(Connection $connection, string $dbPrefix)
+ {
+ $this->connection = $connection;
+ $this->dbPrefix = $dbPrefix;
+ }
+
+ public function createTables(): array
+ {
+ $errors = [];
+ $this->dropTables();
+ $sqlInstallFile = dirname(__DIR__) . '/Resources/data/install.sql';
+
+ $sqlContent = file_get_contents($sqlInstallFile);
+ $sqlContent = str_replace('PREFIX_', $this->dbPrefix, $sqlContent);
+
+ $sqlQueries = array_filter(array_map('trim', explode(';', $sqlContent)));
+
+ foreach ($sqlQueries as $query) {
+ if (empty($query)) {
+ continue;
+ }
+
+ try {
+ $this->connection->executeQuery($query);
+ } catch (DBALException $e) {
+ $errors[] = [
+ 'key' => $e->getMessage(),
+ 'parameters' => [],
+ 'domain' => 'Admin.Modules.Notification',
+ ];
+ }
+ }
+ return $errors;
+ }
+
+ public function dropTables(): array
+ {
+ $errors = [];
+ $tableNames = [
+ 'ws_orderreference',
+ ];
+ foreach ($tableNames as $tableName) {
+ $sql = 'DROP TABLE IF EXISTS ' . $this->dbPrefix . $tableName;
+ try {
+ $this->connection->executeQuery($sql);
+ } catch (DBALException $e) {
+ $errors[] = [
+ 'key' => $e->getMessage(),
+ 'parameters' => [],
+ 'domain' => 'Admin.Modules.Notification',
+ ];
+ }
+ }
+ return $errors;
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/OrderReference.php b/src/Entity/OrderReference.php
new file mode 100644
index 0000000..5389805
--- /dev/null
+++ b/src/Entity/OrderReference.php
@@ -0,0 +1,91 @@
+
+ * @copyright 2026 De Websmid
+ * @license Academic Free License 3.0 (AFL-3.0)
+ */
+
+declare(strict_types=1);
+
+namespace Module\WsOrderreference\Entity;
+
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * @ORM\Table()
+ * @ORM\Entity(repositoryClass="Module\WsOrderreference\Repository\OrderReferenceRepository")
+ */
+class OrderReference
+{
+ /**
+ * @var int
+ *
+ * @ORM\Id
+ * @ORM\Column(name="id", type="integer")
+ * @ORM\GeneratedValue(strategy="AUTO")
+ */
+ private $id;
+
+ /**
+ * @var int
+ *
+ * @ORM\Column(name="id_cart", type="integer", unique=true)
+ */
+ private $idCart;
+
+ /**
+ * @var string
+ *
+ * @ORM\Column(name="order_reference", type="string", length=255)
+ */
+ private $orderReference;
+
+ /**
+ * @return int|null
+ */
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ /**
+ * @return int|null
+ */
+ public function getIdCart(): ?int
+ {
+ return $this->idCart;
+ }
+
+ /**
+ * @param int $idCart
+ * @return $this
+ */
+ public function setIdCart(int $idCart): self
+ {
+ $this->idCart = $idCart;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getOrderReference(): string
+ {
+ return $this->orderReference;
+ }
+
+ /**
+ * @param string $orderReference
+ * @return $this
+ */
+ public function setOrderReference(string $orderReference): self
+ {
+ $this->orderReference = $orderReference;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/src/Module/Install.php b/src/Module/Install.php
new file mode 100644
index 0000000..f3a325d
--- /dev/null
+++ b/src/Module/Install.php
@@ -0,0 +1,45 @@
+installTables()
+ && parent::install()
+ && $this->registerHook('displayShoppingCart')
+ && $this->registerHook('displayPDFDeliverySlip')
+ && $this->registerHook('actionFrontControllerSetMedia');
+ }
+
+ private function getInstaller(): ReferenceInstaller
+ {
+ try {
+ $installer = $this->get(ReferenceInstaller::class);
+ } catch (\Throwable $e) {
+ $installer = null;
+ }
+
+ if (!$installer) {
+ $installer = new ReferenceInstaller(
+ $this->get('doctrine.dbal.default_connection'),
+ $this->getContainer()->getParameter('database_prefix')
+ );
+ }
+
+ return $installer;
+ }
+
+ private function installTables(): bool
+ {
+ /** @var ReferenceInstaller $installer */
+ $installer = $this->getInstaller();
+ $errors = $installer->createTables();
+
+ return empty($errors);
+ }
+
+}
\ No newline at end of file
diff --git a/src/Module/actionHooks.php b/src/Module/actionHooks.php
new file mode 100644
index 0000000..2b285a0
--- /dev/null
+++ b/src/Module/actionHooks.php
@@ -0,0 +1,25 @@
+ $this->context->link->getModuleLink($this->name, 'ajax'),
+ ]);
+
+ $this->context->controller->registerJavascript(
+ 'index.js',
+ 'modules/'.$this->name.'/views/js/index.js',
+ ['position' => 'bottom', 'priority' => 150]
+ );
+
+ $this->context->controller->registerStylesheet(
+ 'styles.css',
+ 'modules/'.$this->name.'/views/css/styles.css',
+ ['media' => 'all', 'priority' => 150]
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Module/displayHooks.php b/src/Module/displayHooks.php
new file mode 100644
index 0000000..3e9b443
--- /dev/null
+++ b/src/Module/displayHooks.php
@@ -0,0 +1,23 @@
+fetch('module:ws_orderreference/views/templates/front/orderreference.tpl');
+ }
+
+ public function hookDisplayPDFDeliverySlip($params)
+ {
+ $orderId = $params['object']->id_order;
+
+ $orderReferenceRepo = \PrestaShop\PrestaShop\Adapter\SymfonyContainer::getInstance()->get('Module\WsOrderreference\Repository\OrderReferenceRepository');
+ $orderReference = $orderReferenceRepo->customRefByOrderId($orderId);
+
+ $this->context->smarty->assign('orderReference', $orderReference);
+
+ return $this->fetch('module:ws_orderreference/views/templates/pdf/delivery-slip.tpl');
+ }
+}
\ No newline at end of file
diff --git a/src/Repository/OrderReferenceRepository.php b/src/Repository/OrderReferenceRepository.php
new file mode 100644
index 0000000..1492597
--- /dev/null
+++ b/src/Repository/OrderReferenceRepository.php
@@ -0,0 +1,53 @@
+
+ * @copyright 2026 De Websmid
+ * @license Academic Free License 3.0 (AFL-3.0)
+ */
+
+declare(strict_types=1);
+
+namespace Module\WsOrderreference\Repository;
+
+use Doctrine\ORM\EntityRepository;
+use Module\WsOrderreference\Entity\OrderReference;
+
+class OrderReferenceRepository extends EntityRepository
+{
+ /**
+ * Find order reference by cart ID
+ *
+ * @param int $cartId
+ * @return OrderReference|null
+ */
+ public function findByCartId(int $cartId): ?OrderReference
+ {
+ return $this->findOneBy(['idCart' => $cartId]);
+ }
+
+ // Create a function that accepts an order Id and uses the cart ID to check if there is a custom order reference.
+ // Requires an order ID as a parameter.
+ // Returns the order reference if found, otherwise returns null.
+ // Used in the display hook to show the order reference on the pdf.
+
+ // Steps to build and break the problem down:
+ // 1.) Just return the cart ID from the order ID to see if that works.
+ // 2.) Use the cart ID to find the order reference.
+
+ public function customRefByOrderId(int $orderId): ?string
+ {
+ $conn = $this->getEntityManager()->getConnection();
+ $sql = 'SELECT id_cart FROM ps_orders WHERE id_order = :orderId';
+ $stmt = $conn->prepare($sql);
+ $resultSet = $stmt->executeQuery(['orderId' => $orderId]);
+ $idCart = $resultSet->fetchOne();
+ if ($idCart) {
+ $orderReference = $this->findByCartId((int)$idCart);
+ return $orderReference ? $orderReference->getOrderReference() : null;
+ }
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Resources/data/install.sql b/src/Resources/data/install.sql
new file mode 100644
index 0000000..61de717
--- /dev/null
+++ b/src/Resources/data/install.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS `PREFIX_order_reference` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `id_cart` int(11) NOT NULL,
+ `order_reference` varchar(255) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `id_cart` (`id_cart`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
\ No newline at end of file
diff --git a/translations/nl.php b/translations/nl.php
new file mode 100644
index 0000000..ec617ff
--- /dev/null
+++ b/translations/nl.php
@@ -0,0 +1,14 @@
+ws_orderreference_1e860e56970a81a1ba3e1fcb7fccc846'] = 'Orderreferentie';
+$_MODULE['<{ws_orderreference}prestashop>ws_orderreference_9f0d684399938af879ce3fb38710642c'] = 'Voegt een optioneel orderreferentieveld toe aan het winkelwagenoverzicht.';
+$_MODULE['<{ws_orderreference}prestashop>ajax_ec19148e5460115c7abda82799f02269'] = 'Geen winkelwagen gevonden.';
+$_MODULE['<{ws_orderreference}prestashop>ajax_ffc8410c88b32eb4547230088b9df62a'] = 'Voer een geldige orderreferentie in. Toegestane tekens: a-z, 0-9, -, _ en spatie';
+$_MODULE['<{ws_orderreference}prestashop>ajax_588d7a076adcf5a584c049d954496ae6'] = 'Orderreferentie ontvangen!';
+$_MODULE['<{ws_orderreference}prestashop>ajax_4602466a12672337138c0925bd5f737f'] = 'Kon orderreferentie niet opslaan.';
+$_MODULE['<{ws_orderreference}prestashop>orderreference_9f5ba9ae96aa82530d03f434f4da0670'] = 'Orderreferentie (optioneel)';
+$_MODULE['<{ws_orderreference}prestashop>orderreference_a4d3b161ce1309df1c4e25df28694b7b'] = 'Opslaan';
+$_MODULE['<{ws_orderreference}prestashop>orderreference_fe8cc190e37aa5083375088bd5873c29'] = 'Uw bestelreferentie:';
+$_MODULE['<{ws_orderreference}prestashop>delivery-slip_f8c9e6a6b5ac4fd353d569c78cbabd90'] = 'Uw aangepaste bestelreferentie:';
diff --git a/views/css/styles.css b/views/css/styles.css
new file mode 100644
index 0000000..83ad14e
--- /dev/null
+++ b/views/css/styles.css
@@ -0,0 +1,13 @@
+/* Fade out for alert */
+.fade-out {
+ animation: fadeOut 0.75s forwards;
+}
+
+@keyframes fadeOut {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
diff --git a/views/js/index.js b/views/js/index.js
new file mode 100644
index 0000000..c6dd4d5
--- /dev/null
+++ b/views/js/index.js
@@ -0,0 +1,105 @@
+document.addEventListener("DOMContentLoaded", function () {
+ // Load saved reference on page load (with retry for dynamic content)
+ loadSavedReferenceWithRetry();
+
+ // Ajax submission
+ document.addEventListener("submit", function (e) {
+ if (e.target && e.target.id === "order_reference_form") {
+ e.preventDefault();
+ const form = e.target;
+ const url = typeof ajax_url !== "undefined" ? ajax_url : null;
+ const input = document.querySelector("#order_reference_input");
+ const inputValue = input.value.trim();
+
+ fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ },
+ body:
+ "ajax=1&order_reference_input=" +
+ encodeURIComponent(inputValue),
+ })
+ .then((response) => response.json())
+ .then((data) => {
+ if (data.success) {
+ // Save to localStorage
+ localStorage.setItem("order_reference", inputValue);
+ displaySavedReference(inputValue);
+ input.value = "";
+ }
+ alertFadeOut(form, data);
+ })
+ .catch((error) => {
+ console.error(error);
+ alertFadeOut(form, {
+ success: false,
+ message: "An error occurred",
+ });
+ });
+ }
+ });
+
+ // Load saved reference with retry mechanism for dynamic content
+ function loadSavedReferenceWithRetry(attempts = 0, maxAttempts = 10) {
+ const savedReference = localStorage.getItem("order_reference");
+ if (savedReference) {
+ const success = displaySavedReference(savedReference);
+
+ if (!success && attempts < maxAttempts) {
+ setTimeout(() => {
+ loadSavedReferenceWithRetry(attempts + 1, maxAttempts);
+ }, 100);
+ }
+ }
+ }
+
+ function displaySavedReference(value) {
+ const form = document.querySelector("#order_reference_form");
+ if (!form) return false;
+
+ const lastSubmittedContainer = form.querySelector(
+ ".last-submitted-container",
+ );
+ if (lastSubmittedContainer) {
+ const lastSubmittedSpan =
+ lastSubmittedContainer.querySelector(".last-submitted");
+ if (lastSubmittedSpan) {
+ lastSubmittedSpan.textContent = value;
+ lastSubmittedContainer.classList.remove("d-none");
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Reset alert to initial state
+ function resetAlert(alert, messageEl) {
+ alert.classList.add("d-none");
+ alert.classList.remove("alert-success", "alert-danger", "fade-out");
+ messageEl.textContent = "";
+ }
+
+ // Fade out alert and reset
+ function alertFadeOut(form, data) {
+ const alert = form.querySelector(".alert");
+ if (!alert) return;
+
+ const messageEl = alert.querySelector(".alert-message");
+ if (!messageEl) return;
+
+ // Show alert with appropriate class and message
+ alert.classList.remove("d-none");
+ alert.classList.add(data.success ? "alert-success" : "alert-danger");
+ messageEl.textContent = data.message;
+
+ // Fade out after delay
+ setTimeout(() => {
+ alert.classList.add("fade-out");
+ setTimeout(() => {
+ resetAlert(alert, messageEl);
+ }, 750); // 750ms for fade-out animation - make sure it matches in styles.css
+ }, 5000);
+ }
+});
diff --git a/views/templates/front/orderreference.tpl b/views/templates/front/orderreference.tpl
new file mode 100644
index 0000000..278ba13
--- /dev/null
+++ b/views/templates/front/orderreference.tpl
@@ -0,0 +1,21 @@
+
diff --git a/views/templates/pdf/delivery-slip.tpl b/views/templates/pdf/delivery-slip.tpl
new file mode 100644
index 0000000..b892fb3
--- /dev/null
+++ b/views/templates/pdf/delivery-slip.tpl
@@ -0,0 +1 @@
+{l s='Your custom order reference:' mod='ws_orderreference'} {$orderReference}
\ No newline at end of file
diff --git a/ws_orderreference.php b/ws_orderreference.php
new file mode 100644
index 0000000..6e7dbc3
--- /dev/null
+++ b/ws_orderreference.php
@@ -0,0 +1,44 @@
+name = 'ws_orderreference';
+ $this->tab = 'other';
+ $this->version = '1.0.0';
+ $this->author = 'Isabelle Oving-Anno | De Websmid b.v.';
+ $this->need_instance = 0;
+ $this->ps_versions_compliancy = [
+ 'min' => '9.0.0',
+ 'max' => '9.99.99',
+ ];
+ $this -> bootstrap = true;
+
+ parent::__construct();
+
+ $this->displayName = $this->l('Order Reference');
+ $this->description = $this->l('Adds an optional order reference field to the shopping cart summary.');
+ }
+
+}
\ No newline at end of file