-
+
connectionOk) : ?>
-
+
+
+
+ :
+
+
+
+
+ :
+
+
+
+
+
+
-
+
+
+
+
+ :
+
+
+
+
+ connectionOk) : ?>
+
+
+
+
+
+
+
+
+
+
revenue['month'] ?? 0, 2); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ revenue['today'] ?? 0, 2); ?> |
+
+
+ |
+ revenue['week'] ?? 0, 2); ?> |
+
+
+ |
+ revenue['month'] ?? 0, 2); ?> |
+
+
+
+
+
+
+
+
+
+
+ recentOrders)) : ?>
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+ recentOrders as $order) : ?>
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/src/admin/tmpl/orders/default.php b/src/admin/tmpl/orders/default.php
new file mode 100644
index 0000000..d85d507
--- /dev/null
+++ b/src/admin/tmpl/orders/default.php
@@ -0,0 +1,93 @@
+currency);
+?>
+
diff --git a/src/mokodolijoomshop.xml b/src/mokodolijoomshop.xml
index 6a2c626..6ce4583 100644
--- a/src/mokodolijoomshop.xml
+++ b/src/mokodolijoomshop.xml
@@ -37,8 +37,14 @@
+
+ css
+ images
+
+
language
+ services
src
tmpl
@@ -127,10 +133,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
https://git.mokoconsulting.tech/MokoConsulting/MokoDoliJoomShop/raw/branch/main/updates.xml
diff --git a/src/site/language/en-GB/com_mokodolijoomshop.ini b/src/site/language/en-GB/com_mokodolijoomshop.ini
index 19d3a91..7377dd2 100644
--- a/src/site/language/en-GB/com_mokodolijoomshop.ini
+++ b/src/site/language/en-GB/com_mokodolijoomshop.ini
@@ -10,12 +10,106 @@ COM_MOKODOLIJOOMSHOP_CHECKOUT="Checkout"
COM_MOKODOLIJOOMSHOP_ADD_TO_CART="Add to Cart"
COM_MOKODOLIJOOMSHOP_VIEW_CART="View Cart"
COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT="Proceed to Checkout"
+COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING="Continue Shopping"
COM_MOKODOLIJOOMSHOP_CART_EMPTY="Your cart is empty."
+COM_MOKODOLIJOOMSHOP_CART_ITEM_ADDED="Item added to cart."
+COM_MOKODOLIJOOMSHOP_CART_ITEM_REMOVED="Item removed from cart."
+COM_MOKODOLIJOOMSHOP_CART_ADD_FAILED="Unable to add item to cart. Product may be out of stock."
COM_MOKODOLIJOOMSHOP_ORDER_PLACED="Your order has been placed successfully."
COM_MOKODOLIJOOMSHOP_PRICE="Price"
+COM_MOKODOLIJOOMSHOP_PRICE_HT="Price (excl. tax)"
COM_MOKODOLIJOOMSHOP_QUANTITY="Quantity"
COM_MOKODOLIJOOMSHOP_SUBTOTAL="Subtotal"
COM_MOKODOLIJOOMSHOP_TAX="Tax"
COM_MOKODOLIJOOMSHOP_TOTAL="Total"
COM_MOKODOLIJOOMSHOP_IN_STOCK="In Stock"
COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK="Out of Stock"
+COM_MOKODOLIJOOMSHOP_AVAILABLE="available"
+COM_MOKODOLIJOOMSHOP_NO_PRODUCTS="No products found."
+COM_MOKODOLIJOOMSHOP_NO_IMAGE="No image available"
+COM_MOKODOLIJOOMSHOP_DESCRIPTION="Description"
+COM_MOKODOLIJOOMSHOP_RELATED_PRODUCTS="Related Products"
+COM_MOKODOLIJOOMSHOP_PRODUCT_REF="Reference"
+COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL="Product"
+
+COM_MOKODOLIJOOMSHOP_BILLING_DETAILS="Billing Details"
+COM_MOKODOLIJOOMSHOP_BILLING_NAME="Full Name"
+COM_MOKODOLIJOOMSHOP_BILLING_EMAIL="Email Address"
+COM_MOKODOLIJOOMSHOP_BILLING_ADDRESS="Address"
+COM_MOKODOLIJOOMSHOP_BILLING_TOWN="City"
+COM_MOKODOLIJOOMSHOP_BILLING_ZIP="Postal Code"
+COM_MOKODOLIJOOMSHOP_BILLING_PHONE="Phone"
+COM_MOKODOLIJOOMSHOP_ORDER_NOTES="Order Notes"
+COM_MOKODOLIJOOMSHOP_ORDER_NOTES_PLACEHOLDER="Any special instructions for your order..."
+COM_MOKODOLIJOOMSHOP_ORDER_SUMMARY="Order Summary"
+COM_MOKODOLIJOOMSHOP_PLACE_ORDER="Place Order"
+COM_MOKODOLIJOOMSHOP_ORDER_REF="Order Reference"
+COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF="Invoice Reference"
+COM_MOKODOLIJOOMSHOP_NO_ORDER_DATA="No order information available."
+
+COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED="You must be logged in to checkout."
+COM_MOKODOLIJOOMSHOP_CHECKOUT_STOCK_ERROR="Some items in your cart are no longer available in the requested quantity."
+COM_MOKODOLIJOOMSHOP_CHECKOUT_FAILED="Unable to process your order. Please try again."
+
+COM_MOKODOLIJOOMSHOP_CATEGORIES="Categories"
+COM_MOKODOLIJOOMSHOP_CATEGORY="Products by Category"
+COM_MOKODOLIJOOMSHOP_CATEGORY_DESC="Display products from a specific Dolibarr category."
+COM_MOKODOLIJOOMSHOP_CATEGORY_OPTIONS="Category Options"
+COM_MOKODOLIJOOMSHOP_CATEGORY_ID="Category ID"
+COM_MOKODOLIJOOMSHOP_CATEGORY_ID_DESC="The Dolibarr product category ID to display."
+COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC="Display the full product catalog."
+COM_MOKODOLIJOOMSHOP_CART_DESC="Display the shopping cart."
+COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC="Display the checkout form."
+
+COM_MOKODOLIJOOMSHOP_LOW_STOCK="Low Stock"
+COM_MOKODOLIJOOMSHOP_BACKORDER="Available on Backorder"
+
+COM_MOKODOLIJOOMSHOP_MY_ORDERS="My Orders"
+COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC="Display order history for the logged-in user."
+COM_MOKODOLIJOOMSHOP_ORDERS_LOGIN_REQUIRED="Please log in to view your order history."
+COM_MOKODOLIJOOMSHOP_VIEW_DETAIL="View"
+COM_MOKODOLIJOOMSHOP_ORDER_DATE="Date"
+COM_MOKODOLIJOOMSHOP_ORDER_STATUS="Status"
+COM_MOKODOLIJOOMSHOP_NO_ORDERS="You have no orders yet."
+
+COM_MOKODOLIJOOMSHOP_SEARCH="Search"
+COM_MOKODOLIJOOMSHOP_SEARCH_PLACEHOLDER="Search products..."
+COM_MOKODOLIJOOMSHOP_SORT_BY="Sort by"
+COM_MOKODOLIJOOMSHOP_SORT_REF_ASC="Reference (A-Z)"
+COM_MOKODOLIJOOMSHOP_SORT_REF_DESC="Reference (Z-A)"
+COM_MOKODOLIJOOMSHOP_SORT_PRICE_ASC="Price (Low to High)"
+COM_MOKODOLIJOOMSHOP_SORT_PRICE_DESC="Price (High to Low)"
+COM_MOKODOLIJOOMSHOP_SORT_NEWEST="Newest"
+COM_MOKODOLIJOOMSHOP_FILTER_PRICE="Price Range"
+COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING="Continue Shopping"
+COM_MOKODOLIJOOMSHOP_USE_GLOBAL="Use Global"
+
+COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC="Display a single product detail page."
+COM_MOKODOLIJOOMSHOP_PRODUCT_OPTIONS="Product Options"
+COM_MOKODOLIJOOMSHOP_PRODUCT_ID="Product ID"
+COM_MOKODOLIJOOMSHOP_PRODUCT_ID_DESC="The Dolibarr product ID to display."
+
+COM_MOKODOLIJOOMSHOP_SELECT_VARIANT="Select %s"
+COM_MOKODOLIJOOMSHOP_VARIANT_UNAVAILABLE="Variant unavailable"
+
+COM_MOKODOLIJOOMSHOP_WISHLIST="Wishlist"
+COM_MOKODOLIJOOMSHOP_ADD_TO_WISHLIST="Add to Wishlist"
+COM_MOKODOLIJOOMSHOP_REMOVE_FROM_WISHLIST="Remove from Wishlist"
+COM_MOKODOLIJOOMSHOP_WISHLIST_EMPTY="Your wishlist is empty."
+COM_MOKODOLIJOOMSHOP_WISHLIST_ADDED="Item added to wishlist."
+COM_MOKODOLIJOOMSHOP_MOVE_TO_CART="Move to Cart"
+
+COM_MOKODOLIJOOMSHOP_COUPON_CODE="Coupon Code"
+COM_MOKODOLIJOOMSHOP_APPLY_COUPON="Apply"
+COM_MOKODOLIJOOMSHOP_COUPON_APPLIED="Discount applied: %s"
+COM_MOKODOLIJOOMSHOP_COUPON_INVALID="Invalid coupon code."
+COM_MOKODOLIJOOMSHOP_DISCOUNT="Discount"
+
+COM_MOKODOLIJOOMSHOP_MY_ADDRESSES="My Addresses"
+COM_MOKODOLIJOOMSHOP_ADD_ADDRESS="Add Address"
+COM_MOKODOLIJOOMSHOP_EDIT_ADDRESS="Edit Address"
+COM_MOKODOLIJOOMSHOP_DEFAULT_ADDRESS="Default"
+COM_MOKODOLIJOOMSHOP_ADDRESS_LABEL="Label (e.g., Home, Office)"
+
+COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND="Invoice PDF not available."
+COM_MOKODOLIJOOMSHOP_DOWNLOAD_INVOICE="Download Invoice"
diff --git a/src/site/services/provider.php b/src/site/services/provider.php
index c168b90..59e3054 100644
--- a/src/site/services/provider.php
+++ b/src/site/services/provider.php
@@ -8,4 +8,5 @@
defined('_JEXEC') or die;
-// Site service provider — component registration handled by admin provider
+// Site service provider — component registration is handled by the admin provider.
+// This file must exist but no additional services are needed for the site side.
diff --git a/src/site/src/Controller/CartController.php b/src/site/src/Controller/CartController.php
new file mode 100644
index 0000000..2b2eb21
--- /dev/null
+++ b/src/site/src/Controller/CartController.php
@@ -0,0 +1,104 @@
+input->getInt('product_id', 0);
+ $quantity = $this->input->getInt('quantity', 1);
+
+ /** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
+ $model = $this->getModel('Cart');
+
+ if ($model->addItem($productId, $quantity))
+ {
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ITEM_ADDED'), 'success');
+ }
+ else
+ {
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ADD_FAILED'), 'error');
+ }
+
+ $return = $this->input->getBase64('return', '');
+
+ if ($return)
+ {
+ $this->setRedirect(base64_decode($return));
+ }
+ else
+ {
+ $this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
+ }
+ }
+
+ /**
+ * Update cart item quantity.
+ *
+ * @return void
+ *
+ * @since 1.0.0
+ */
+ public function update(): void
+ {
+ Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
+
+ $cartItemId = $this->input->getInt('cart_item_id', 0);
+ $quantity = $this->input->getInt('quantity', 1);
+
+ /** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
+ $model = $this->getModel('Cart');
+ $model->updateItemQuantity($cartItemId, $quantity);
+
+ $this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
+ }
+
+ /**
+ * Remove a cart item.
+ *
+ * @return void
+ *
+ * @since 1.0.0
+ */
+ public function remove(): void
+ {
+ Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
+
+ $cartItemId = $this->input->getInt('cart_item_id', 0);
+
+ /** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
+ $model = $this->getModel('Cart');
+ $model->removeItem($cartItemId);
+
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ITEM_REMOVED'), 'success');
+ $this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
+ }
+}
diff --git a/src/site/src/Controller/CheckoutController.php b/src/site/src/Controller/CheckoutController.php
new file mode 100644
index 0000000..204b680
--- /dev/null
+++ b/src/site/src/Controller/CheckoutController.php
@@ -0,0 +1,102 @@
+getModel('Checkout');
+
+ if (!$checkoutModel->canCheckout())
+ {
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED'), 'warning');
+ $this->setRedirect(Route::_('index.php?option=com_users&view=login', false));
+
+ return;
+ }
+
+ /** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $cartModel */
+ $cartModel = $this->getModel('Cart');
+ $cartItems = $cartModel->getItems();
+
+ if (empty($cartItems))
+ {
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'), 'warning');
+ $this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
+
+ return;
+ }
+
+ // Validate stock before proceeding
+ $stockProblems = $cartModel->validateStock();
+
+ if (!empty($stockProblems))
+ {
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_STOCK_ERROR'), 'error');
+ $this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
+
+ return;
+ }
+
+ // Collect billing data from form
+ $billingData = [
+ 'name' => $this->input->getString('billing_name', ''),
+ 'email' => $this->input->getString('billing_email', ''),
+ 'address' => $this->input->getString('billing_address', ''),
+ 'town' => $this->input->getString('billing_town', ''),
+ 'zip' => $this->input->getString('billing_zip', ''),
+ 'phone' => $this->input->getString('billing_phone', ''),
+ 'notes' => $this->input->getString('order_notes', ''),
+ ];
+
+ $totals = $cartModel->getTotals();
+ $result = $checkoutModel->processCheckout($billingData, $cartItems, $totals);
+
+ if ($result === null)
+ {
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_FAILED'), 'error');
+ $this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=checkout', false));
+
+ return;
+ }
+
+ // Clear the cart on success
+ $cartModel->clearCart();
+
+ // Store result in session for confirmation page
+ $session = $this->app->getSession();
+ $session->set('mokodolijoomshop.order_result', $result);
+
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_ORDER_PLACED'), 'success');
+ $this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=checkout&layout=confirmation', false));
+ }
+}
diff --git a/src/site/src/Controller/InvoiceController.php b/src/site/src/Controller/InvoiceController.php
new file mode 100644
index 0000000..237e926
--- /dev/null
+++ b/src/site/src/Controller/InvoiceController.php
@@ -0,0 +1,121 @@
+getIdentity()->id;
+ $invoiceId = $this->input->getInt('invoice_id', 0);
+
+ if ($userId === 0 || $invoiceId === 0)
+ {
+ $this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
+ $this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
+
+ return;
+ }
+
+ // Verify ownership
+ $db = Factory::getContainer()->get('DatabaseDriver');
+ $query = $db->getQuery(true);
+ $query->select($db->quoteName('invoice_ref'))
+ ->from($db->quoteName('#__mokodolijoomshop_orders'))
+ ->where($db->quoteName('user_id') . ' = ' . $userId)
+ ->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
+ $db->setQuery($query);
+ $invoiceRef = $db->loadResult();
+
+ if ($invoiceRef === null)
+ {
+ $this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
+ $this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
+
+ return;
+ }
+
+ // Fetch PDF from Dolibarr
+ $client = new DolibarrClient();
+ $docs = $client->get('/documents', [
+ 'modulepart' => 'invoice',
+ 'id' => $invoiceId,
+ ]);
+
+ $pdfDoc = null;
+
+ if (!empty($docs))
+ {
+ foreach ($docs as $doc)
+ {
+ if (isset($doc['relativename']) && str_ends_with($doc['relativename'], '.pdf'))
+ {
+ $pdfDoc = $doc;
+ break;
+ }
+ }
+ }
+
+ if ($pdfDoc === null)
+ {
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND'), 'warning');
+ $this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
+
+ return;
+ }
+
+ // Download content
+ $download = $client->get('/documents/download', [
+ 'modulepart' => 'invoice',
+ 'original_file' => $pdfDoc['relativename'],
+ ]);
+
+ if (empty($download['content']))
+ {
+ $this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND'), 'warning');
+ $this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
+
+ return;
+ }
+
+ $pdfContent = base64_decode($download['content']);
+ $filename = $invoiceRef . '.pdf';
+
+ // Stream PDF to browser
+ $this->app->setHeader('Content-Type', 'application/pdf');
+ $this->app->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
+ $this->app->setHeader('Content-Length', (string) \strlen($pdfContent));
+ $this->app->sendHeaders();
+
+ echo $pdfContent;
+ $this->app->close();
+ }
+}
diff --git a/src/site/src/Controller/SearchController.php b/src/site/src/Controller/SearchController.php
new file mode 100644
index 0000000..ec3dd9d
--- /dev/null
+++ b/src/site/src/Controller/SearchController.php
@@ -0,0 +1,111 @@
+get('products_per_page', 12);
+
+ $q = $this->input->getString('q', '');
+ $categoryId = $this->input->getInt('category_id', 0);
+ $priceMin = $this->input->getFloat('price_min', 0);
+ $priceMax = $this->input->getFloat('price_max', 0);
+ $sort = $this->input->getString('sort', 'ref_asc');
+ $page = $this->input->getInt('page', 0);
+
+ // Build sort parameters
+ $sortMap = [
+ 'ref_asc' => ['t.ref', 'ASC'],
+ 'ref_desc' => ['t.ref', 'DESC'],
+ 'label_asc' => ['t.label', 'ASC'],
+ 'label_desc' => ['t.label', 'DESC'],
+ 'price_asc' => ['t.price', 'ASC'],
+ 'price_desc' => ['t.price', 'DESC'],
+ 'newest' => ['t.datec', 'DESC'],
+ ];
+
+ $sortField = $sortMap[$sort][0] ?? 't.ref';
+ $sortOrder = $sortMap[$sort][1] ?? 'ASC';
+
+ $query = [
+ 'sortfield' => $sortField,
+ 'sortorder' => $sortOrder,
+ 'limit' => $perPage,
+ 'page' => $page,
+ ];
+
+ if ($categoryId > 0)
+ {
+ $query['category'] = $categoryId;
+ }
+
+ // Build sqlfilters for text search and price range
+ $filters = [];
+
+ if (!empty($q))
+ {
+ $escaped = addslashes($q);
+ $filters[] = "(t.label:like:'%{$escaped}%') or (t.ref:like:'%{$escaped}%') or (t.description:like:'%{$escaped}%')";
+ }
+
+ if ($priceMin > 0)
+ {
+ $filters[] = "(t.price:>=:{$priceMin})";
+ }
+
+ if ($priceMax > 0)
+ {
+ $filters[] = "(t.price:<=:{$priceMax})";
+ }
+
+ if (!empty($filters))
+ {
+ $query['sqlfilters'] = implode(' and ', $filters);
+ }
+
+ $products = $client->get('/products', $query);
+
+ // Return JSON response
+ $this->app->setHeader('Content-Type', 'application/json');
+ $this->app->sendHeaders();
+ echo json_encode([
+ 'success' => true,
+ 'products' => $products ?? [],
+ 'page' => $page,
+ 'per_page' => $perPage,
+ ]);
+
+ $this->app->close();
+ }
+}
diff --git a/src/site/src/Helper/CouponHelper.php b/src/site/src/Helper/CouponHelper.php
new file mode 100644
index 0000000..07a36a7
--- /dev/null
+++ b/src/site/src/Helper/CouponHelper.php
@@ -0,0 +1,103 @@
+client = new DolibarrClient();
+ }
+
+ /**
+ * Validate a coupon code and return the discount details.
+ *
+ * @param string $code Coupon code entered by user.
+ * @param int $thirdpartyId Customer thirdparty ID (for customer-specific discounts).
+ *
+ * @return array|null Discount data or null if invalid.
+ *
+ * @since 1.0.0
+ */
+ public function validate(string $code, int $thirdpartyId = 0): ?array
+ {
+ if (empty(trim($code)))
+ {
+ return null;
+ }
+
+ // Search for discount rules matching this code in Dolibarr
+ // Dolibarr stores available discounts per thirdparty
+ if ($thirdpartyId > 0)
+ {
+ $discounts = $this->client->get('/thirdparties/' . $thirdpartyId . '/availablediscounts');
+
+ if (!empty($discounts))
+ {
+ foreach ($discounts as $discount)
+ {
+ if (($discount['description'] ?? '') === $code || ($discount['ref'] ?? '') === $code)
+ {
+ return [
+ 'id' => (int) ($discount['id'] ?? 0),
+ 'type' => !empty($discount['percent']) ? 'percent' : 'fixed',
+ 'value' => (float) ($discount['percent'] ?? $discount['amount_ttc'] ?? 0),
+ 'amount_ht' => (float) ($discount['amount_ht'] ?? 0),
+ 'amount_ttc' => (float) ($discount['amount_ttc'] ?? 0),
+ 'description' => $discount['description'] ?? $code,
+ ];
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Apply a discount to a cart total.
+ *
+ * @param array $discount Discount data from validate().
+ * @param float $subtotal Cart subtotal before discount.
+ *
+ * @return float Discount amount to subtract.
+ *
+ * @since 1.0.0
+ */
+ public function calculateDiscount(array $discount, float $subtotal): float
+ {
+ if ($discount['type'] === 'percent')
+ {
+ return round($subtotal * ($discount['value'] / 100), 4);
+ }
+
+ // Fixed amount — don't exceed subtotal
+ return min($discount['amount_ttc'], $subtotal);
+ }
+}
diff --git a/src/site/src/Helper/StockHelper.php b/src/site/src/Helper/StockHelper.php
new file mode 100644
index 0000000..c8de71d
--- /dev/null
+++ b/src/site/src/Helper/StockHelper.php
@@ -0,0 +1,116 @@
+get('low_stock_threshold', 5);
+
+ if ($stockQty <= $threshold)
+ {
+ return self::STATUS_LOW_STOCK;
+ }
+
+ return self::STATUS_IN_STOCK;
+ }
+
+ /**
+ * Render a Bootstrap badge for stock status.
+ *
+ * @param float $stockQty Stock quantity.
+ * @param bool $showQty Whether to show the numeric quantity.
+ *
+ * @return string HTML badge markup.
+ *
+ * @since 1.0.0
+ */
+ public static function renderBadge(float $stockQty, bool $showQty = false): string
+ {
+ $status = self::getStatus($stockQty);
+
+ switch ($status)
+ {
+ case self::STATUS_IN_STOCK:
+ $class = 'bg-success';
+ $text = Text::_('COM_MOKODOLIJOOMSHOP_IN_STOCK');
+ break;
+
+ case self::STATUS_LOW_STOCK:
+ $class = 'bg-warning text-dark';
+ $text = Text::_('COM_MOKODOLIJOOMSHOP_LOW_STOCK');
+ break;
+
+ case self::STATUS_OUT:
+ default:
+ $class = 'bg-danger';
+ $text = Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK');
+ break;
+ }
+
+ if ($showQty && $stockQty > 0)
+ {
+ $text .= ' (' . (int) $stockQty . ')';
+ }
+
+ return '
' . $text . '';
+ }
+
+ /**
+ * Check if add-to-cart should be enabled.
+ *
+ * @param float $stockQty Stock quantity.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public static function canAddToCart(float $stockQty): bool
+ {
+ if ($stockQty > 0)
+ {
+ return true;
+ }
+
+ // Check if backorders are allowed
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+
+ return (bool) $params->get('allow_backorder', false);
+ }
+}
diff --git a/src/site/src/Helper/TaxHelper.php b/src/site/src/Helper/TaxHelper.php
new file mode 100644
index 0000000..daf538e
--- /dev/null
+++ b/src/site/src/Helper/TaxHelper.php
@@ -0,0 +1,159 @@
+get('tax_display', 'ttc');
+ }
+
+ /**
+ * Check if tax is enabled.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public static function isEnabled(): bool
+ {
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+
+ return (bool) $params->get('tax_enabled', true);
+ }
+
+ /**
+ * Calculate tax breakdown grouped by rate from cart items.
+ *
+ * @param array $cartItems Cart items with 'unit_price', 'quantity', 'tax_rate'.
+ *
+ * @return array Array of [rate => amount], e.g., [20.0 => 40.00, 5.0 => 2.50].
+ *
+ * @since 1.0.0
+ */
+ public static function getGroupedTax(array $cartItems): array
+ {
+ $grouped = [];
+
+ foreach ($cartItems as $item)
+ {
+ $rate = (float) ($item['tax_rate'] ?? 0);
+ $lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
+ $taxAmount = $lineTotal * ($rate / 100);
+
+ if ($rate > 0)
+ {
+ if (!isset($grouped[$rate]))
+ {
+ $grouped[$rate] = 0.0;
+ }
+
+ $grouped[$rate] += $taxAmount;
+ }
+ }
+
+ ksort($grouped);
+
+ return $grouped;
+ }
+
+ /**
+ * Format a price for display based on the tax display mode.
+ *
+ * @param float $priceHT Price excluding tax.
+ * @param float $priceTTC Price including tax.
+ * @param string $currency Currency code.
+ *
+ * @return string Formatted price string.
+ *
+ * @since 1.0.0
+ */
+ public static function formatPrice(float $priceHT, float $priceTTC, string $currency): string
+ {
+ $mode = self::getDisplayMode();
+
+ switch ($mode)
+ {
+ case 'ht':
+ return number_format($priceHT, 2) . ' ' . $currency . ' HT';
+
+ case 'both':
+ return number_format($priceTTC, 2) . ' ' . $currency
+ . '
(' . number_format($priceHT, 2) . ' HT)';
+
+ case 'ttc':
+ default:
+ return number_format($priceTTC, 2) . ' ' . $currency;
+ }
+ }
+
+ /**
+ * Calculate totals from cart items including tax breakdown.
+ *
+ * @param array $cartItems Cart items.
+ *
+ * @return array{subtotal_ht: float, tax_total: float, total_ttc: float, tax_grouped: array}
+ *
+ * @since 1.0.0
+ */
+ public static function calculateTotals(array $cartItems): array
+ {
+ $subtotalHT = 0.0;
+ $taxTotal = 0.0;
+ $grouped = [];
+
+ foreach ($cartItems as $item)
+ {
+ $rate = (float) ($item['tax_rate'] ?? 0);
+ $lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
+ $lineTax = $lineTotal * ($rate / 100);
+
+ $subtotalHT += $lineTotal;
+ $taxTotal += $lineTax;
+
+ if ($rate > 0)
+ {
+ if (!isset($grouped[$rate]))
+ {
+ $grouped[$rate] = 0.0;
+ }
+
+ $grouped[$rate] += $lineTax;
+ }
+ }
+
+ ksort($grouped);
+
+ return [
+ 'subtotal_ht' => $subtotalHT,
+ 'tax_total' => $taxTotal,
+ 'total_ttc' => $subtotalHT + $taxTotal,
+ 'tax_grouped' => $grouped,
+ ];
+ }
+}
diff --git a/src/site/src/Helper/VariantHelper.php b/src/site/src/Helper/VariantHelper.php
new file mode 100644
index 0000000..8a262c0
--- /dev/null
+++ b/src/site/src/Helper/VariantHelper.php
@@ -0,0 +1,171 @@
+client = new DolibarrClient();
+ }
+
+ /**
+ * Get variants for a product.
+ *
+ * @param int $productId Parent product ID.
+ *
+ * @return array Array of variant data.
+ *
+ * @since 1.0.0
+ */
+ public function getVariants(int $productId): array
+ {
+ $variants = $this->client->get('/products/' . $productId . '/variants');
+
+ if ($variants === null || !\is_array($variants))
+ {
+ return [];
+ }
+
+ return $variants;
+ }
+
+ /**
+ * Check if a product has variants.
+ *
+ * @param int $productId Product ID.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public function hasVariants(int $productId): bool
+ {
+ $variants = $this->getVariants($productId);
+
+ return !empty($variants);
+ }
+
+ /**
+ * Parse variants into grouped attribute selectors.
+ *
+ * Returns a structure like:
+ * [
+ * 'Color' => ['Red' => [...], 'Blue' => [...]],
+ * 'Size' => ['S' => [...], 'M' => [...], 'L' => [...]],
+ * ]
+ *
+ * @param array $variants Raw variants from Dolibarr.
+ *
+ * @return array Grouped attributes.
+ *
+ * @since 1.0.0
+ */
+ public function groupByAttribute(array $variants): array
+ {
+ $grouped = [];
+
+ foreach ($variants as $variant)
+ {
+ $attributes = $variant['attributes'] ?? [];
+
+ foreach ($attributes as $attr)
+ {
+ $attrName = $attr['attribute'] ?? $attr['ref'] ?? 'Option';
+ $attrValue = $attr['value'] ?? $attr['ref_ext'] ?? '';
+
+ if (!isset($grouped[$attrName]))
+ {
+ $grouped[$attrName] = [];
+ }
+
+ if (!isset($grouped[$attrName][$attrValue]))
+ {
+ $grouped[$attrName][$attrValue] = [];
+ }
+
+ $grouped[$attrName][$attrValue][] = [
+ 'variant_id' => (int) ($variant['fk_product_child'] ?? $variant['id'] ?? 0),
+ 'ref' => $variant['ref'] ?? '',
+ 'price_diff' => (float) ($variant['variation_price'] ?? 0),
+ 'price_type' => $variant['variation_price_percentage'] ?? false,
+ 'stock' => (float) ($variant['stock_reel'] ?? 0),
+ ];
+ }
+ }
+
+ return $grouped;
+ }
+
+ /**
+ * Build JSON data for variant selectors (consumed by frontend JS).
+ *
+ * @param int $productId Parent product ID.
+ * @param float $basePrice Base product price.
+ *
+ * @return array Variant config for JSON encoding.
+ *
+ * @since 1.0.0
+ */
+ public function getVariantConfig(int $productId, float $basePrice): array
+ {
+ $variants = $this->getVariants($productId);
+
+ if (empty($variants))
+ {
+ return [];
+ }
+
+ $config = [
+ 'base_price' => $basePrice,
+ 'variants' => [],
+ ];
+
+ foreach ($variants as $variant)
+ {
+ $childId = (int) ($variant['fk_product_child'] ?? $variant['id'] ?? 0);
+ $priceDiff = (float) ($variant['variation_price'] ?? 0);
+ $isPercent = !empty($variant['variation_price_percentage']);
+ $finalPrice = $isPercent
+ ? $basePrice * (1 + $priceDiff / 100)
+ : $basePrice + $priceDiff;
+
+ $config['variants'][] = [
+ 'id' => $childId,
+ 'ref' => $variant['ref'] ?? '',
+ 'attributes' => $variant['attributes'] ?? [],
+ 'price' => round($finalPrice, 4),
+ 'price_diff' => $priceDiff,
+ 'stock' => (float) ($variant['stock_reel'] ?? 0),
+ ];
+ }
+
+ return $config;
+ }
+}
diff --git a/src/site/src/Model/AddressModel.php b/src/site/src/Model/AddressModel.php
new file mode 100644
index 0000000..f95bec4
--- /dev/null
+++ b/src/site/src/Model/AddressModel.php
@@ -0,0 +1,176 @@
+getIdentity()->id;
+
+ if ($userId === 0)
+ {
+ return [];
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->select('*')
+ ->from($db->quoteName('#__mokodolijoomshop_addresses'))
+ ->where($db->quoteName('user_id') . ' = ' . $userId)
+ ->order($db->quoteName('is_default') . ' DESC, ' . $db->quoteName('label') . ' ASC');
+
+ $db->setQuery($query);
+
+ return $db->loadAssocList() ?: [];
+ }
+
+ /**
+ * Get the default address for the current user.
+ *
+ * @return array|null
+ *
+ * @since 1.0.0
+ */
+ public function getDefaultAddress(): ?array
+ {
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+
+ if ($userId === 0)
+ {
+ return null;
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->select('*')
+ ->from($db->quoteName('#__mokodolijoomshop_addresses'))
+ ->where($db->quoteName('user_id') . ' = ' . $userId)
+ ->where($db->quoteName('is_default') . ' = 1');
+
+ $db->setQuery($query, 0, 1);
+ $result = $db->loadAssoc();
+
+ return $result ?: null;
+ }
+
+ /**
+ * Save an address.
+ *
+ * @param array $data Address data.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public function saveAddress(array $data): bool
+ {
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+
+ if ($userId === 0)
+ {
+ return false;
+ }
+
+ $db = $this->getDatabase();
+
+ // If setting as default, clear other defaults first
+ if (!empty($data['is_default']))
+ {
+ $clear = $db->getQuery(true);
+ $clear->update($db->quoteName('#__mokodolijoomshop_addresses'))
+ ->set($db->quoteName('is_default') . ' = 0')
+ ->where($db->quoteName('user_id') . ' = ' . $userId);
+ $db->setQuery($clear);
+ $db->execute();
+ }
+
+ $id = (int) ($data['id'] ?? 0);
+
+ if ($id > 0)
+ {
+ // Update existing
+ $query = $db->getQuery(true);
+ $query->update($db->quoteName('#__mokodolijoomshop_addresses'))
+ ->set($db->quoteName('label') . ' = ' . $db->quote($data['label'] ?? ''))
+ ->set($db->quoteName('name') . ' = ' . $db->quote($data['name'] ?? ''))
+ ->set($db->quoteName('address') . ' = ' . $db->quote($data['address'] ?? ''))
+ ->set($db->quoteName('town') . ' = ' . $db->quote($data['town'] ?? ''))
+ ->set($db->quoteName('zip') . ' = ' . $db->quote($data['zip'] ?? ''))
+ ->set($db->quoteName('country_code') . ' = ' . $db->quote($data['country_code'] ?? ''))
+ ->set($db->quoteName('phone') . ' = ' . $db->quote($data['phone'] ?? ''))
+ ->set($db->quoteName('is_default') . ' = ' . (int) ($data['is_default'] ?? 0))
+ ->where($db->quoteName('id') . ' = ' . $id)
+ ->where($db->quoteName('user_id') . ' = ' . $userId);
+ $db->setQuery($query);
+
+ return $db->execute() !== false;
+ }
+
+ // Insert new
+ $query = $db->getQuery(true);
+ $query->insert($db->quoteName('#__mokodolijoomshop_addresses'))
+ ->columns(['user_id', 'label', 'name', 'address', 'town', 'zip', 'country_code', 'phone', 'is_default'])
+ ->values(implode(',', [
+ $userId,
+ $db->quote($data['label'] ?? ''),
+ $db->quote($data['name'] ?? ''),
+ $db->quote($data['address'] ?? ''),
+ $db->quote($data['town'] ?? ''),
+ $db->quote($data['zip'] ?? ''),
+ $db->quote($data['country_code'] ?? ''),
+ $db->quote($data['phone'] ?? ''),
+ (int) ($data['is_default'] ?? 0),
+ ]));
+ $db->setQuery($query);
+
+ return $db->execute() !== false;
+ }
+
+ /**
+ * Delete an address.
+ *
+ * @param int $addressId Address ID.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public function deleteAddress(int $addressId): bool
+ {
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+
+ $query->delete($db->quoteName('#__mokodolijoomshop_addresses'))
+ ->where($db->quoteName('id') . ' = ' . $addressId)
+ ->where($db->quoteName('user_id') . ' = ' . $userId);
+
+ $db->setQuery($query);
+
+ return $db->execute() !== false;
+ }
+}
diff --git a/src/site/src/Model/CartModel.php b/src/site/src/Model/CartModel.php
new file mode 100644
index 0000000..8986458
--- /dev/null
+++ b/src/site/src/Model/CartModel.php
@@ -0,0 +1,385 @@
+getSession()->getId();
+ }
+
+ /**
+ * Get the current user ID (0 for guests).
+ *
+ * @return int
+ *
+ * @since 1.0.0
+ */
+ public function getUserId(): int
+ {
+ return (int) Factory::getApplication()->getIdentity()->id;
+ }
+
+ /**
+ * Get all cart items for the current user/session.
+ *
+ * @return array
+ *
+ * @since 1.0.0
+ */
+ public function getItems(): array
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->select('*')
+ ->from($db->quoteName('#__mokodolijoomshop_cart'));
+
+ $userId = $this->getUserId();
+
+ if ($userId > 0)
+ {
+ $query->where($db->quoteName('user_id') . ' = ' . $userId);
+ }
+ else
+ {
+ $query->where($db->quoteName('session_id') . ' = ' . $db->quote($this->getSessionId()));
+ }
+
+ $query->order($db->quoteName('created') . ' ASC');
+ $db->setQuery($query);
+
+ return $db->loadAssocList() ?: [];
+ }
+
+ /**
+ * Add an item to the cart.
+ *
+ * @param int $productId Dolibarr product ID.
+ * @param int $quantity Quantity to add.
+ *
+ * @return bool True on success.
+ *
+ * @since 1.0.0
+ */
+ public function addItem(int $productId, int $quantity = 1): bool
+ {
+ // Fetch product info from Dolibarr
+ $client = new DolibarrClient();
+ $product = $client->get('/products/' . $productId);
+
+ if ($product === null)
+ {
+ return false;
+ }
+
+ // Validate stock
+ $stockReel = (float) ($product['stock_reel'] ?? 0);
+
+ if ($stockReel <= 0)
+ {
+ return false;
+ }
+
+ $db = $this->getDatabase();
+ $userId = $this->getUserId();
+ $sessionId = $this->getSessionId();
+
+ // Check if this product already exists in cart
+ $existing = $this->findCartItem($productId);
+
+ if ($existing)
+ {
+ $newQty = (int) $existing['quantity'] + $quantity;
+
+ if ($newQty > $stockReel)
+ {
+ $newQty = (int) $stockReel;
+ }
+
+ return $this->updateItemQuantity((int) $existing['id'], $newQty);
+ }
+
+ // Clamp quantity to stock
+ if ($quantity > $stockReel)
+ {
+ $quantity = (int) $stockReel;
+ }
+
+ $table = $this->getTable('Cart', 'Administrator');
+ $data = [
+ 'session_id' => $sessionId,
+ 'user_id' => $userId,
+ 'dolibarr_product_id' => $productId,
+ 'product_ref' => $product['ref'] ?? '',
+ 'product_label' => $product['label'] ?? '',
+ 'quantity' => $quantity,
+ 'unit_price' => (float) ($product['price_ttc'] ?? $product['price'] ?? 0),
+ 'tax_rate' => (float) ($product['tva_tx'] ?? 0),
+ ];
+
+ $table->bind($data);
+
+ if (!$table->check())
+ {
+ return false;
+ }
+
+ return $table->store();
+ }
+
+ /**
+ * Update quantity of a cart item.
+ *
+ * @param int $cartItemId Cart row ID.
+ * @param int $quantity New quantity.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public function updateItemQuantity(int $cartItemId, int $quantity): bool
+ {
+ if ($quantity < 1)
+ {
+ return $this->removeItem($cartItemId);
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->update($db->quoteName('#__mokodolijoomshop_cart'))
+ ->set($db->quoteName('quantity') . ' = ' . $quantity)
+ ->where($db->quoteName('id') . ' = ' . $cartItemId);
+
+ $this->addOwnerCondition($query);
+ $db->setQuery($query);
+
+ return $db->execute() !== false;
+ }
+
+ /**
+ * Remove an item from the cart.
+ *
+ * @param int $cartItemId Cart row ID.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public function removeItem(int $cartItemId): bool
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->delete($db->quoteName('#__mokodolijoomshop_cart'))
+ ->where($db->quoteName('id') . ' = ' . $cartItemId);
+
+ $this->addOwnerCondition($query);
+ $db->setQuery($query);
+
+ return $db->execute() !== false;
+ }
+
+ /**
+ * Clear all items from the current cart.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public function clearCart(): bool
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->delete($db->quoteName('#__mokodolijoomshop_cart'));
+ $this->addOwnerCondition($query);
+ $db->setQuery($query);
+
+ return $db->execute() !== false;
+ }
+
+ /**
+ * Merge guest session cart into the logged-in user's cart.
+ *
+ * @param string $sessionId Guest session ID.
+ * @param int $userId Logged-in user ID.
+ *
+ * @return void
+ *
+ * @since 1.0.0
+ */
+ public function mergeGuestCart(string $sessionId, int $userId): void
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+
+ // Update guest cart items to belong to the user
+ $query->update($db->quoteName('#__mokodolijoomshop_cart'))
+ ->set($db->quoteName('user_id') . ' = ' . $userId)
+ ->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId))
+ ->where($db->quoteName('user_id') . ' = 0');
+
+ $db->setQuery($query);
+ $db->execute();
+ }
+
+ /**
+ * Delete cart items older than the specified number of hours.
+ *
+ * @param int $hours Age threshold in hours.
+ *
+ * @return int Number of rows deleted.
+ *
+ * @since 1.0.0
+ */
+ public function cleanExpired(int $hours = 72): int
+ {
+ $db = $this->getDatabase();
+ $cutoff = Factory::getDate('-' . $hours . ' hours')->toSql();
+ $query = $db->getQuery(true);
+ $query->delete($db->quoteName('#__mokodolijoomshop_cart'))
+ ->where($db->quoteName('modified') . ' < ' . $db->quote($cutoff))
+ ->where($db->quoteName('user_id') . ' = 0');
+
+ $db->setQuery($query);
+ $db->execute();
+
+ return $db->getAffectedRows();
+ }
+
+ /**
+ * Get cart totals.
+ *
+ * @return array{subtotal: float, tax: float, total: float, count: int}
+ *
+ * @since 1.0.0
+ */
+ public function getTotals(): array
+ {
+ $items = $this->getItems();
+ $subtotal = 0.0;
+ $tax = 0.0;
+ $count = 0;
+
+ foreach ($items as $item)
+ {
+ $lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
+ $lineTax = $lineTotal * ((float) $item['tax_rate'] / 100);
+ $subtotal += $lineTotal;
+ $tax += $lineTax;
+ $count += (int) $item['quantity'];
+ }
+
+ return [
+ 'subtotal' => $subtotal,
+ 'tax' => $tax,
+ 'total' => $subtotal + $tax,
+ 'count' => $count,
+ ];
+ }
+
+ /**
+ * Validate stock levels for all cart items against Dolibarr.
+ *
+ * @return array Array of items with insufficient stock: [product_id => available_qty].
+ *
+ * @since 1.0.0
+ */
+ public function validateStock(): array
+ {
+ $client = new DolibarrClient();
+ $items = $this->getItems();
+ $problems = [];
+
+ foreach ($items as $item)
+ {
+ $product = $client->get('/products/' . (int) $item['dolibarr_product_id']);
+
+ if ($product === null)
+ {
+ $problems[(int) $item['dolibarr_product_id']] = 0;
+ continue;
+ }
+
+ $stockReel = (float) ($product['stock_reel'] ?? 0);
+
+ if ((int) $item['quantity'] > $stockReel)
+ {
+ $problems[(int) $item['dolibarr_product_id']] = $stockReel;
+ }
+ }
+
+ return $problems;
+ }
+
+ /**
+ * Find an existing cart item by product ID for the current user/session.
+ *
+ * @param int $productId Dolibarr product ID.
+ *
+ * @return array|null
+ *
+ * @since 1.0.0
+ */
+ private function findCartItem(int $productId): ?array
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->select('*')
+ ->from($db->quoteName('#__mokodolijoomshop_cart'))
+ ->where($db->quoteName('dolibarr_product_id') . ' = ' . $productId);
+
+ $this->addOwnerCondition($query);
+ $db->setQuery($query);
+ $result = $db->loadAssoc();
+
+ return $result ?: null;
+ }
+
+ /**
+ * Add user/session ownership condition to a query.
+ *
+ * @param \Joomla\Database\DatabaseQuery $query Query to modify.
+ *
+ * @return void
+ *
+ * @since 1.0.0
+ */
+ private function addOwnerCondition($query): void
+ {
+ $db = $this->getDatabase();
+ $userId = $this->getUserId();
+
+ if ($userId > 0)
+ {
+ $query->where($db->quoteName('user_id') . ' = ' . $userId);
+ }
+ else
+ {
+ $query->where($db->quoteName('session_id') . ' = ' . $db->quote($this->getSessionId()));
+ }
+ }
+}
diff --git a/src/site/src/Model/CategoryModel.php b/src/site/src/Model/CategoryModel.php
new file mode 100644
index 0000000..fe221d4
--- /dev/null
+++ b/src/site/src/Model/CategoryModel.php
@@ -0,0 +1,190 @@
+client = new DolibarrClient();
+ }
+
+ /**
+ * Get a single category by ID.
+ *
+ * @param int|null $id Category ID, or null to read from input.
+ *
+ * @return array|null
+ *
+ * @since 1.0.0
+ */
+ public function getCategory(?int $id = null): ?array
+ {
+ if ($id === null)
+ {
+ $id = Factory::getApplication()->input->getInt('id', 0);
+ }
+
+ if ($id <= 0)
+ {
+ return null;
+ }
+
+ return $this->client->get('/categories/' . $id);
+ }
+
+ /**
+ * Get all product categories as a flat list.
+ *
+ * @return array
+ *
+ * @since 1.0.0
+ */
+ public function getAllCategories(): array
+ {
+ $categories = $this->client->get('/categories', [
+ 'sortfield' => 't.label',
+ 'sortorder' => 'ASC',
+ 'type' => 'product',
+ 'limit' => 200,
+ ]);
+
+ return $categories ?? [];
+ }
+
+ /**
+ * Build a hierarchical category tree.
+ *
+ * @return array Nested array with 'children' key.
+ *
+ * @since 1.0.0
+ */
+ public function getCategoryTree(): array
+ {
+ $flat = $this->getAllCategories();
+ $tree = [];
+ $map = [];
+
+ // Index by ID
+ foreach ($flat as $cat)
+ {
+ $cat['children'] = [];
+ $map[(int) $cat['id']] = $cat;
+ }
+
+ // Build tree
+ foreach ($map as $id => &$cat)
+ {
+ $parentId = (int) ($cat['fk_parent'] ?? 0);
+
+ if ($parentId > 0 && isset($map[$parentId]))
+ {
+ $map[$parentId]['children'][] = &$cat;
+ }
+ else
+ {
+ $tree[] = &$cat;
+ }
+ }
+
+ unset($cat);
+
+ return $tree;
+ }
+
+ /**
+ * Get products belonging to a category.
+ *
+ * @param int $categoryId Category ID.
+ *
+ * @return array
+ *
+ * @since 1.0.0
+ */
+ public function getCategoryProducts(int $categoryId): array
+ {
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+ $perPage = (int) $params->get('products_per_page', 12);
+ $app = Factory::getApplication();
+ $page = $app->input->getInt('page', 0);
+
+ $products = $this->client->get('/products', [
+ 'sortfield' => 't.ref',
+ 'sortorder' => 'ASC',
+ 'limit' => $perPage,
+ 'page' => $page,
+ 'category' => $categoryId,
+ ]);
+
+ return $products ?? [];
+ }
+
+ /**
+ * Build breadcrumb trail for a category.
+ *
+ * @param int $categoryId Category ID.
+ *
+ * @return array Array of [id, label] from root to current.
+ *
+ * @since 1.0.0
+ */
+ public function getBreadcrumbs(int $categoryId): array
+ {
+ $crumbs = [];
+ $visited = [];
+ $current = $categoryId;
+
+ while ($current > 0 && !isset($visited[$current]))
+ {
+ $visited[$current] = true;
+ $cat = $this->client->get('/categories/' . $current);
+
+ if ($cat === null)
+ {
+ break;
+ }
+
+ array_unshift($crumbs, [
+ 'id' => (int) $cat['id'],
+ 'label' => $cat['label'] ?? '',
+ ]);
+
+ $current = (int) ($cat['fk_parent'] ?? 0);
+ }
+
+ return $crumbs;
+ }
+}
diff --git a/src/site/src/Model/CheckoutModel.php b/src/site/src/Model/CheckoutModel.php
new file mode 100644
index 0000000..545f393
--- /dev/null
+++ b/src/site/src/Model/CheckoutModel.php
@@ -0,0 +1,137 @@
+get('checkout_mode', 'both');
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+
+ if ($mode === 'registered' && $userId === 0)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the configured checkout mode.
+ *
+ * @return string 'guest', 'registered', or 'both'.
+ *
+ * @since 1.0.0
+ */
+ public function getCheckoutMode(): string
+ {
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+
+ return $params->get('checkout_mode', 'both');
+ }
+
+ /**
+ * Process the checkout.
+ *
+ * @param array $billingData Billing form data.
+ * @param array $cartItems Cart items from CartModel.
+ * @param array $totals Cart totals.
+ *
+ * @return array|null Order result with refs, or null on failure.
+ *
+ * @since 1.0.0
+ */
+ public function processCheckout(array $billingData, array $cartItems, array $totals): ?array
+ {
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+ $customerService = new CustomerSyncService();
+ $orderService = new OrderService();
+
+ // Resolve or create the Dolibarr thirdparty
+ if ($userId > 0)
+ {
+ $thirdpartyId = $customerService->getOrCreateThirdparty($userId);
+ }
+ else
+ {
+ $thirdpartyId = $customerService->createGuestCustomer(
+ $billingData['name'] ?? 'Guest Customer',
+ $billingData['email'] ?? '',
+ $billingData['address'] ?? '',
+ $billingData['town'] ?? '',
+ $billingData['zip'] ?? '',
+ $billingData['phone'] ?? ''
+ );
+ }
+
+ if ($thirdpartyId === null)
+ {
+ return null;
+ }
+
+ // Create order in Dolibarr
+ $order = $orderService->createOrder($thirdpartyId, $cartItems, [
+ 'note_public' => $billingData['notes'] ?? '',
+ ]);
+
+ if ($order === null)
+ {
+ return null;
+ }
+
+ $orderId = (int) ($order['id'] ?? 0);
+ $orderRef = $order['ref'] ?? '';
+
+ // Create invoice from order
+ $invoice = $orderService->createInvoiceFromOrder($orderId);
+ $invoiceId = (int) ($invoice['id'] ?? 0);
+ $invoiceRef = $invoice['ref'] ?? '';
+
+ // Save local mapping
+ $orderService->saveOrderMapping(
+ $userId,
+ $orderId,
+ $invoiceId,
+ $thirdpartyId,
+ $orderRef,
+ $invoiceRef,
+ $totals['subtotal'],
+ $totals['total']
+ );
+
+ return [
+ 'order_id' => $orderId,
+ 'order_ref' => $orderRef,
+ 'invoice_id' => $invoiceId,
+ 'invoice_ref' => $invoiceRef,
+ ];
+ }
+}
diff --git a/src/site/src/Model/OrdersModel.php b/src/site/src/Model/OrdersModel.php
new file mode 100644
index 0000000..0c1b765
--- /dev/null
+++ b/src/site/src/Model/OrdersModel.php
@@ -0,0 +1,194 @@
+client = new DolibarrClient();
+ }
+
+ /**
+ * Get orders for the currently logged-in user.
+ *
+ * @return array
+ *
+ * @since 1.0.0
+ */
+ public function getUserOrders(): array
+ {
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+
+ if ($userId === 0)
+ {
+ return [];
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->select('*')
+ ->from($db->quoteName('#__mokodolijoomshop_orders'))
+ ->where($db->quoteName('user_id') . ' = ' . $userId)
+ ->order($db->quoteName('created') . ' DESC');
+
+ $db->setQuery($query);
+
+ return $db->loadAssocList() ?: [];
+ }
+
+ /**
+ * Get a single order detail from Dolibarr.
+ *
+ * @param int $orderId Dolibarr order ID.
+ *
+ * @return array|null
+ *
+ * @since 1.0.0
+ */
+ public function getOrderDetail(int $orderId): ?array
+ {
+ // Verify the order belongs to the current user
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+
+ if ($userId === 0)
+ {
+ return null;
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->select($db->quoteName('dolibarr_order_id'))
+ ->from($db->quoteName('#__mokodolijoomshop_orders'))
+ ->where($db->quoteName('user_id') . ' = ' . $userId)
+ ->where($db->quoteName('dolibarr_order_id') . ' = ' . $orderId);
+
+ $db->setQuery($query);
+
+ if ($db->loadResult() === null)
+ {
+ return null;
+ }
+
+ return $this->client->get('/orders/' . $orderId);
+ }
+
+ /**
+ * Get invoice PDF download URL from Dolibarr.
+ *
+ * @param int $invoiceId Dolibarr invoice ID.
+ *
+ * @return array|null Document info with download data.
+ *
+ * @since 1.0.0
+ */
+ public function getInvoicePdf(int $invoiceId): ?array
+ {
+ // Verify user has access to this invoice
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+
+ if ($userId === 0)
+ {
+ return null;
+ }
+
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->select($db->quoteName('dolibarr_invoice_id'))
+ ->from($db->quoteName('#__mokodolijoomshop_orders'))
+ ->where($db->quoteName('user_id') . ' = ' . $userId)
+ ->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
+
+ $db->setQuery($query);
+
+ if ($db->loadResult() === null)
+ {
+ return null;
+ }
+
+ // Get invoice documents
+ $docs = $this->client->get('/documents', [
+ 'modulepart' => 'invoice',
+ 'id' => $invoiceId,
+ ]);
+
+ if (empty($docs))
+ {
+ return null;
+ }
+
+ // Find PDF
+ foreach ($docs as $doc)
+ {
+ if (isset($doc['relativename']) && str_ends_with($doc['relativename'], '.pdf'))
+ {
+ return $doc;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get real-time order status from Dolibarr.
+ *
+ * @param int $orderId Dolibarr order ID.
+ *
+ * @return string Status label.
+ *
+ * @since 1.0.0
+ */
+ public function getOrderStatus(int $orderId): string
+ {
+ $order = $this->client->get('/orders/' . $orderId);
+
+ if ($order === null)
+ {
+ return 'unknown';
+ }
+
+ $statusMap = [
+ -1 => 'cancelled',
+ 0 => 'draft',
+ 1 => 'validated',
+ 2 => 'shipped',
+ 3 => 'delivered',
+ ];
+
+ $statusCode = (int) ($order['statut'] ?? $order['status'] ?? 0);
+
+ return $statusMap[$statusCode] ?? 'unknown';
+ }
+}
diff --git a/src/site/src/Model/ProductModel.php b/src/site/src/Model/ProductModel.php
new file mode 100644
index 0000000..6203f23
--- /dev/null
+++ b/src/site/src/Model/ProductModel.php
@@ -0,0 +1,179 @@
+client = new DolibarrClient();
+ }
+
+ /**
+ * Get a single product by ID.
+ *
+ * @param int|null $id Product ID, or null to read from input.
+ *
+ * @return array|null Product data or null if not found.
+ *
+ * @since 1.0.0
+ */
+ public function getItem(?int $id = null): ?array
+ {
+ if ($id === null)
+ {
+ $id = Factory::getApplication()->input->getInt('id', 0);
+ }
+
+ if ($id <= 0)
+ {
+ return null;
+ }
+
+ return $this->client->get('/products/' . $id);
+ }
+
+ /**
+ * Get stock level for a product.
+ *
+ * @param int $productId Dolibarr product ID.
+ *
+ * @return float Total stock across all warehouses.
+ *
+ * @since 1.0.0
+ */
+ public function getStock(int $productId): float
+ {
+ $stockData = $this->client->get('/products/' . $productId . '/stock');
+
+ if ($stockData === null)
+ {
+ return 0.0;
+ }
+
+ // Sum stock across warehouses
+ $total = 0.0;
+
+ if (isset($stockData['stock_warehouses']) && \is_array($stockData['stock_warehouses']))
+ {
+ foreach ($stockData['stock_warehouses'] as $warehouse)
+ {
+ $total += (float) ($warehouse['real'] ?? 0);
+ }
+ }
+ else
+ {
+ $total = (float) ($stockData['stock_reel'] ?? 0);
+ }
+
+ return $total;
+ }
+
+ /**
+ * Get product images from Dolibarr documents API.
+ *
+ * @param int $productId Dolibarr product ID.
+ * @param string $ref Product reference for path building.
+ *
+ * @return array Array of image URLs.
+ *
+ * @since 1.0.0
+ */
+ public function getImages(int $productId, string $ref = ''): array
+ {
+ $docs = $this->client->get('/documents', [
+ 'modulepart' => 'product',
+ 'id' => $productId,
+ ]);
+
+ if ($docs === null || !\is_array($docs))
+ {
+ return [];
+ }
+
+ $images = [];
+
+ foreach ($docs as $doc)
+ {
+ if (isset($doc['relativename']) && preg_match('/\.(jpe?g|png|gif|webp)$/i', $doc['relativename']))
+ {
+ $images[] = [
+ 'name' => $doc['name'] ?? basename($doc['relativename']),
+ 'url' => $doc['fullname'] ?? $doc['relativename'],
+ 'encoded' => $doc['content'] ?? null,
+ ];
+ }
+ }
+
+ return $images;
+ }
+
+ /**
+ * Get related products from the same category.
+ *
+ * @param int $productId Current product ID.
+ * @param int $limit Number of related products to return.
+ *
+ * @return array
+ *
+ * @since 1.0.0
+ */
+ public function getRelated(int $productId, int $limit = 4): array
+ {
+ // Get categories for this product
+ $categories = $this->client->get('/products/' . $productId . '/categories');
+
+ if (empty($categories))
+ {
+ return [];
+ }
+
+ $catId = (int) $categories[0]['id'];
+ $products = $this->client->get('/categories/' . $catId . '/objects', [
+ 'type' => 'product',
+ 'limit' => $limit + 1,
+ ]);
+
+ if (empty($products))
+ {
+ return [];
+ }
+
+ // Remove the current product from related
+ return array_values(array_filter($products, function ($p) use ($productId) {
+ return (int) $p['id'] !== $productId;
+ }));
+ }
+}
diff --git a/src/site/src/Model/ProductsModel.php b/src/site/src/Model/ProductsModel.php
new file mode 100644
index 0000000..c2a43be
--- /dev/null
+++ b/src/site/src/Model/ProductsModel.php
@@ -0,0 +1,137 @@
+client = new DolibarrClient();
+ }
+
+ /**
+ * Get a list of products from Dolibarr.
+ *
+ * @return array Array of product objects.
+ *
+ * @since 1.0.0
+ */
+ public function getItems(): array
+ {
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+ $perPage = (int) $params->get('products_per_page', 12);
+ $app = Factory::getApplication();
+ $page = $app->input->getInt('page', 0);
+ $categoryId = $app->input->getInt('category_id', 0);
+
+ $query = [
+ 'sortfield' => 't.ref',
+ 'sortorder' => 'ASC',
+ 'limit' => $perPage,
+ 'page' => $page,
+ ];
+
+ if ($categoryId > 0)
+ {
+ $query['category'] = $categoryId;
+ }
+
+ $products = $this->client->get('/products', $query);
+
+ if ($products === null)
+ {
+ return [];
+ }
+
+ // Filter to only saleable products
+ return array_values(array_filter($products, function ($product) {
+ return !empty($product['status_buy']) || !empty($product['tosell']) || ((int) ($product['status'] ?? 0)) === 1;
+ }));
+ }
+
+ /**
+ * Get product categories from Dolibarr.
+ *
+ * @return array Array of category objects.
+ *
+ * @since 1.0.0
+ */
+ public function getCategories(): array
+ {
+ $categories = $this->client->get('/categories', [
+ 'sortfield' => 't.label',
+ 'sortorder' => 'ASC',
+ 'type' => 'product',
+ ]);
+
+ return $categories ?? [];
+ }
+
+ /**
+ * Get total product count for pagination.
+ *
+ * @return int
+ *
+ * @since 1.0.0
+ */
+ public function getTotal(): int
+ {
+ $products = $this->client->get('/products', [
+ 'limit' => 0,
+ ]);
+
+ if ($products === null)
+ {
+ return 0;
+ }
+
+ return \count($products);
+ }
+
+ /**
+ * Get the number of products per page.
+ *
+ * @return int
+ *
+ * @since 1.0.0
+ */
+ public function getPerPage(): int
+ {
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+
+ return (int) $params->get('products_per_page', 12);
+ }
+}
diff --git a/src/site/src/Model/WishlistModel.php b/src/site/src/Model/WishlistModel.php
new file mode 100644
index 0000000..f5ffe05
--- /dev/null
+++ b/src/site/src/Model/WishlistModel.php
@@ -0,0 +1,172 @@
+getDatabase();
+ $query = $db->getQuery(true);
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+
+ $query->select('*')
+ ->from($db->quoteName('#__mokodolijoomshop_wishlist'))
+ ->order($db->quoteName('created') . ' DESC');
+
+ if ($userId > 0)
+ {
+ $query->where($db->quoteName('user_id') . ' = ' . $userId);
+ }
+ else
+ {
+ $sessionId = Factory::getApplication()->getSession()->getId();
+ $query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
+ }
+
+ $db->setQuery($query);
+
+ return $db->loadAssocList() ?: [];
+ }
+
+ /**
+ * Add a product to the wishlist.
+ *
+ * @param int $productId Dolibarr product ID.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public function addItem(int $productId): bool
+ {
+ $client = new DolibarrClient();
+ $product = $client->get('/products/' . $productId);
+
+ if ($product === null)
+ {
+ return false;
+ }
+
+ $db = $this->getDatabase();
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+ $sessionId = Factory::getApplication()->getSession()->getId();
+
+ // Check if already in wishlist
+ $query = $db->getQuery(true);
+ $query->select('COUNT(*)')
+ ->from($db->quoteName('#__mokodolijoomshop_wishlist'))
+ ->where($db->quoteName('dolibarr_product_id') . ' = ' . $productId);
+
+ if ($userId > 0)
+ {
+ $query->where($db->quoteName('user_id') . ' = ' . $userId);
+ }
+ else
+ {
+ $query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
+ }
+
+ $db->setQuery($query);
+
+ if ((int) $db->loadResult() > 0)
+ {
+ return true; // Already in wishlist
+ }
+
+ $insert = $db->getQuery(true);
+ $insert->insert($db->quoteName('#__mokodolijoomshop_wishlist'))
+ ->columns(['user_id', 'session_id', 'dolibarr_product_id', 'product_ref', 'product_label'])
+ ->values(implode(',', [
+ $userId,
+ $db->quote($sessionId),
+ $productId,
+ $db->quote($product['ref'] ?? ''),
+ $db->quote($product['label'] ?? ''),
+ ]));
+
+ $db->setQuery($insert);
+
+ return $db->execute() !== false;
+ }
+
+ /**
+ * Remove a product from the wishlist.
+ *
+ * @param int $wishlistItemId Wishlist row ID.
+ *
+ * @return bool
+ *
+ * @since 1.0.0
+ */
+ public function removeItem(int $wishlistItemId): bool
+ {
+ $db = $this->getDatabase();
+ $userId = (int) Factory::getApplication()->getIdentity()->id;
+ $query = $db->getQuery(true);
+
+ $query->delete($db->quoteName('#__mokodolijoomshop_wishlist'))
+ ->where($db->quoteName('id') . ' = ' . $wishlistItemId);
+
+ if ($userId > 0)
+ {
+ $query->where($db->quoteName('user_id') . ' = ' . $userId);
+ }
+ else
+ {
+ $sessionId = Factory::getApplication()->getSession()->getId();
+ $query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
+ }
+
+ $db->setQuery($query);
+
+ return $db->execute() !== false;
+ }
+
+ /**
+ * Merge guest wishlist into user account on login.
+ *
+ * @param string $sessionId Guest session ID.
+ * @param int $userId User ID.
+ *
+ * @return void
+ *
+ * @since 1.0.0
+ */
+ public function mergeGuestWishlist(string $sessionId, int $userId): void
+ {
+ $db = $this->getDatabase();
+ $query = $db->getQuery(true);
+ $query->update($db->quoteName('#__mokodolijoomshop_wishlist'))
+ ->set($db->quoteName('user_id') . ' = ' . $userId)
+ ->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId))
+ ->where($db->quoteName('user_id') . ' = 0');
+
+ $db->setQuery($query);
+ $db->execute();
+ }
+}
diff --git a/src/site/src/Service/Router.php b/src/site/src/Service/Router.php
new file mode 100644
index 0000000..ad16bea
--- /dev/null
+++ b/src/site/src/Service/Router.php
@@ -0,0 +1,187 @@
+ 1)
+ {
+ $vars['id'] = (int) $segments[1];
+ }
+
+ break;
+
+ case 'category':
+ $vars['view'] = 'category';
+
+ if ($count > 1)
+ {
+ $vars['id'] = (int) $segments[1];
+ }
+
+ break;
+
+ case 'cart':
+ $vars['view'] = 'cart';
+ break;
+
+ case 'checkout':
+ $vars['view'] = 'checkout';
+ break;
+
+ case 'my-orders':
+ $vars['view'] = 'orders';
+
+ if ($count > 1)
+ {
+ $vars['order_id'] = (int) $segments[1];
+ }
+
+ break;
+
+ default:
+ // Try to resolve as product ID or fall back
+ if (is_numeric($first))
+ {
+ $vars['view'] = 'product';
+ $vars['id'] = (int) $first;
+ }
+ else
+ {
+ $vars['view'] = 'products';
+ }
+
+ break;
+ }
+
+ $segments = [];
+
+ return $vars;
+ }
+}
diff --git a/src/site/src/View/Cart/HtmlView.php b/src/site/src/View/Cart/HtmlView.php
new file mode 100644
index 0000000..b22848b
--- /dev/null
+++ b/src/site/src/View/Cart/HtmlView.php
@@ -0,0 +1,61 @@
+getModel();
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+
+ $this->items = $model->getItems();
+ $this->totals = $model->getTotals();
+ $this->currency = $params->get('currency', 'USD');
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/site/src/View/Category/HtmlView.php b/src/site/src/View/Category/HtmlView.php
new file mode 100644
index 0000000..4c6f465
--- /dev/null
+++ b/src/site/src/View/Category/HtmlView.php
@@ -0,0 +1,99 @@
+getModel();
+ $app = Factory::getApplication();
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+
+ $categoryId = $app->input->getInt('id', 0);
+
+ $this->category = $model->getCategory($categoryId);
+ $this->categoryTree = $model->getCategoryTree();
+ $this->currency = $params->get('currency', 'USD');
+ $this->page = $app->input->getInt('page', 0);
+ $this->perPage = (int) $params->get('products_per_page', 12);
+
+ if ($this->category !== null)
+ {
+ $this->items = $model->getCategoryProducts($categoryId);
+ $this->breadcrumbs = $model->getBreadcrumbs($categoryId);
+
+ $app->getDocument()->setTitle(htmlspecialchars($this->category['label'] ?? 'Category'));
+ }
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/site/src/View/Checkout/HtmlView.php b/src/site/src/View/Checkout/HtmlView.php
new file mode 100644
index 0000000..2d85495
--- /dev/null
+++ b/src/site/src/View/Checkout/HtmlView.php
@@ -0,0 +1,94 @@
+input->getString('layout', 'default');
+
+ $this->currency = $params->get('currency', 'USD');
+ $this->checkoutMode = $params->get('checkout_mode', 'both');
+ $this->user = $app->getIdentity();
+
+ if ($layout === 'confirmation')
+ {
+ $this->orderResult = $app->getSession()->get('mokodolijoomshop.order_result');
+ $app->getSession()->clear('mokodolijoomshop.order_result');
+ }
+ else
+ {
+ /** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $cartModel */
+ $cartModel = $this->getModel('Cart');
+ $this->cartItems = $cartModel->getItems();
+ $this->totals = $cartModel->getTotals();
+ }
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/site/src/View/Orders/HtmlView.php b/src/site/src/View/Orders/HtmlView.php
new file mode 100644
index 0000000..125d59b
--- /dev/null
+++ b/src/site/src/View/Orders/HtmlView.php
@@ -0,0 +1,82 @@
+getModel();
+
+ $this->currency = $params->get('currency', 'USD');
+ $this->isGuest = empty($app->getIdentity()->id);
+
+ if (!$this->isGuest)
+ {
+ $orderId = $app->input->getInt('order_id', 0);
+
+ if ($orderId > 0)
+ {
+ $this->orderDetail = $model->getOrderDetail($orderId);
+ }
+ else
+ {
+ $this->orders = $model->getUserOrders();
+ }
+ }
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/site/src/View/Product/HtmlView.php b/src/site/src/View/Product/HtmlView.php
new file mode 100644
index 0000000..1e89fef
--- /dev/null
+++ b/src/site/src/View/Product/HtmlView.php
@@ -0,0 +1,85 @@
+getModel();
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+
+ $this->item = $model->getItem();
+ $this->currency = $params->get('currency', 'USD');
+
+ if ($this->item !== null)
+ {
+ $productId = (int) $this->item['id'];
+ $this->stock = $model->getStock($productId);
+ $this->images = $model->getImages($productId, $this->item['ref'] ?? '');
+ $this->related = $model->getRelated($productId);
+
+ // Set page title
+ $app = Factory::getApplication();
+ $app->getDocument()->setTitle(htmlspecialchars($this->item['label'] ?? 'Product'));
+ }
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/site/src/View/Products/HtmlView.php b/src/site/src/View/Products/HtmlView.php
new file mode 100644
index 0000000..8ddf969
--- /dev/null
+++ b/src/site/src/View/Products/HtmlView.php
@@ -0,0 +1,84 @@
+getModel();
+ $app = Factory::getApplication();
+ $params = ComponentHelper::getParams('com_mokodolijoomshop');
+
+ $this->items = $model->getItems();
+ $this->categories = $model->getCategories();
+ $this->page = $app->input->getInt('page', 0);
+ $this->categoryId = $app->input->getInt('category_id', 0);
+ $this->currency = $params->get('currency', 'USD');
+ $this->perPage = $model->getPerPage();
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/site/tmpl/cart/default.php b/src/site/tmpl/cart/default.php
new file mode 100644
index 0000000..5442219
--- /dev/null
+++ b/src/site/tmpl/cart/default.php
@@ -0,0 +1,106 @@
+currency);
+?>
+
diff --git a/src/site/tmpl/cart/default.xml b/src/site/tmpl/cart/default.xml
new file mode 100644
index 0000000..39ca314
--- /dev/null
+++ b/src/site/tmpl/cart/default.xml
@@ -0,0 +1,6 @@
+
+
+
+ COM_MOKODOLIJOOMSHOP_CART_DESC
+
+
diff --git a/src/site/tmpl/category/default.php b/src/site/tmpl/category/default.php
new file mode 100644
index 0000000..9a8b866
--- /dev/null
+++ b/src/site/tmpl/category/default.php
@@ -0,0 +1,159 @@
+category === null) :
+?>
+
+ currency);
+$catLabel = htmlspecialchars($this->category['label'] ?? '');
+$catDesc = $this->category['description'] ?? '';
+$categoryId = (int) $this->category['id'];
+?>
+
+
+';
+
+ foreach ($tree as $cat)
+ {
+ $id = (int) $cat['id'];
+ $label = htmlspecialchars($cat['label'] ?? '');
+ $active = ($id === $activeId) ? ' active' : '';
+ $link = Route::_('index.php?option=com_mokodolijoomshop&view=category&id=' . $id);
+ $html .= '
';
+ $html .= '' . $label . '';
+
+ if (!empty($cat['children']))
+ {
+ $html .= mokoshop_render_category_tree($cat['children'], $activeId);
+ }
+
+ $html .= '';
+ }
+
+ $html .= '';
+
+ return $html;
+ }
+}
+?>
diff --git a/src/site/tmpl/category/default.xml b/src/site/tmpl/category/default.xml
new file mode 100644
index 0000000..1792b69
--- /dev/null
+++ b/src/site/tmpl/category/default.xml
@@ -0,0 +1,17 @@
+
+
+
+ COM_MOKODOLIJOOMSHOP_CATEGORY_DESC
+
+
+
+
+
diff --git a/src/site/tmpl/checkout/confirmation.php b/src/site/tmpl/checkout/confirmation.php
new file mode 100644
index 0000000..0f129d6
--- /dev/null
+++ b/src/site/tmpl/checkout/confirmation.php
@@ -0,0 +1,47 @@
+orderResult;
+?>
+
diff --git a/src/site/tmpl/checkout/default.php b/src/site/tmpl/checkout/default.php
new file mode 100644
index 0000000..724a721
--- /dev/null
+++ b/src/site/tmpl/checkout/default.php
@@ -0,0 +1,129 @@
+currency);
+$isGuest = empty($this->user->id);
+$userName = $isGuest ? '' : htmlspecialchars($this->user->name);
+$userEmail = $isGuest ? '' : htmlspecialchars($this->user->email);
+?>
+
diff --git a/src/site/tmpl/checkout/default.xml b/src/site/tmpl/checkout/default.xml
new file mode 100644
index 0000000..1bcbc01
--- /dev/null
+++ b/src/site/tmpl/checkout/default.xml
@@ -0,0 +1,6 @@
+
+
+
+ COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC
+
+
diff --git a/src/site/tmpl/orders/default.php b/src/site/tmpl/orders/default.php
new file mode 100644
index 0000000..6ff68e6
--- /dev/null
+++ b/src/site/tmpl/orders/default.php
@@ -0,0 +1,126 @@
+currency);
+?>
+
diff --git a/src/site/tmpl/orders/default.xml b/src/site/tmpl/orders/default.xml
new file mode 100644
index 0000000..a23fdf8
--- /dev/null
+++ b/src/site/tmpl/orders/default.xml
@@ -0,0 +1,6 @@
+
+
+
+ COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC
+
+
diff --git a/src/site/tmpl/product/default.php b/src/site/tmpl/product/default.php
new file mode 100644
index 0000000..eee6ccf
--- /dev/null
+++ b/src/site/tmpl/product/default.php
@@ -0,0 +1,159 @@
+item === null) :
+?>
+
+ item;
+$ref = htmlspecialchars($product['ref'] ?? '');
+$label = htmlspecialchars($product['label'] ?? $ref);
+$description = $product['description'] ?? '';
+$priceHT = (float) ($product['price'] ?? 0);
+$priceTTC = (float) ($product['price_ttc'] ?? $priceHT);
+$barcode = htmlspecialchars($product['barcode'] ?? '');
+$inStock = $this->stock > 0;
+$productId = (int) $product['id'];
+$addCartLink = Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId);
+?>
+
+
+
+
+
diff --git a/src/site/tmpl/product/default.xml b/src/site/tmpl/product/default.xml
new file mode 100644
index 0000000..efea2b6
--- /dev/null
+++ b/src/site/tmpl/product/default.xml
@@ -0,0 +1,17 @@
+
+
+
+ COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC
+
+
+
+
+
diff --git a/src/site/tmpl/products/default.php b/src/site/tmpl/products/default.php
new file mode 100644
index 0000000..50942a9
--- /dev/null
+++ b/src/site/tmpl/products/default.php
@@ -0,0 +1,103 @@
+
+
diff --git a/src/site/tmpl/products/default.xml b/src/site/tmpl/products/default.xml
new file mode 100644
index 0000000..50fa5e9
--- /dev/null
+++ b/src/site/tmpl/products/default.xml
@@ -0,0 +1,31 @@
+
+
+
+ COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC
+
+
+
+
+