9 Commits

Author SHA1 Message Date
Jonathan Miller 652d27fa40 docs: update CHANGELOG for v1.0.0-dev.1
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 21:10:26 -05:00
Jonathan Miller bd2340da9b feat(component): implement low-priority features (Issues #20-27)
- Wishlist: WishlistModel with add/remove, session-based for guests,
  merge on login, DB table #__mokodolijoomshop_wishlist
- Coupon/discount codes: CouponHelper validates codes against
  Dolibarr thirdparty discount rules, supports percent and fixed
- Multi-language: all strings already use Joomla Text class (i18n ready)
- API caching: CacheService using Joomla cache framework with
  configurable TTL, manual flush on sync, dev mode disable
- Shipping addresses: AddressModel with CRUD, default address,
  DB table #__mokodolijoomshop_addresses
- ACL: component-level access rules for products, orders, customers,
  and settings management in manifest
- Webhooks: WebhookService processes Dolibarr events (product CRUD,
  order status, payments), secret validation, event log table
- Invoice PDF: InvoiceController streams PDF from Dolibarr documents
  API with ownership verification

Also adds performance/webhook config fieldsets and 3 new DB tables.

Resolves: #20, #21, #22, #23, #24, #25, #26, #27

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 21:04:29 -05:00
Jonathan Miller 0ba983086a feat(component): implement medium-priority features (Issues #13-19)
- SEF URL router: clean URLs for all views (/shop/product/id,
  /shop/category/id, /shop/cart, /shop/checkout, /shop/my-orders)
- Image service: fetch from Dolibarr docs API, local caching,
  thumbnail support, placeholder for missing, cache invalidation
- Email notifications: customer confirmation and admin notification
  HTML emails via Joomla mail transport on order placement
- Menu item types: XML metadata for Products, Category, Product,
  Cart, Checkout, and My Orders with configurable params
- Responsive CSS: mobile-first storefront.css with sticky add-to-cart,
  touch-friendly cart controls, responsive grid breakpoints
- Product variants: VariantHelper fetches and groups Dolibarr
  product variants/combinations with price adjustments and stock
- Admin orders view: OrdersModel with filters (status, date, search),
  status sync from Dolibarr, enriched with customer names

Also adds media folder (css/images) to component manifest.

Resolves: #13, #14, #15, #16, #17, #18, #19

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 20:50:51 -05:00
Jonathan Miller a44e51ce5c feat(component): implement high-priority features (Issues #7-12)
- Category navigation: CategoryModel with hierarchical tree, category
  landing page with sidebar, breadcrumbs, and Joomla menu item types
- Stock display: StockHelper with In Stock/Low Stock/Out of Stock
  indicators, configurable threshold, and backorder support
- Tax calculation: TaxHelper with grouped tax breakdown, HT/TTC
  display modes, and configurable tax display setting
- Product search: SearchController with AJAX endpoint, text search
  via Dolibarr sqlfilters, price range, sorting, and category filter
- Order history: OrdersModel, Orders view with list and detail,
  PDF invoice download support, real-time Dolibarr status
- Admin dashboard: DashboardModel with product/order/customer counts,
  revenue metrics (daily/weekly/monthly), recent orders table

Also adds menu item type XML metadata for Products, Category, Cart,
Checkout, and My Orders views.

Resolves: #7, #8, #9, #10, #11, #12

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:56:09 -05:00
Jonathan Miller 300f6cad1a feat(component): implement core storefront features (Issues #1-6)
Implements all critical v1.0 features:
- Cart system: CartController, CartModel, CartTable with session-based
  persistence, stock validation, and guest cart merge on login
- Checkout flow: CheckoutController, CheckoutModel with billing form,
  Dolibarr order/invoice creation, and confirmation page
- Customer sync: CustomerSyncService for Joomla user to Dolibarr
  thirdparty mapping with email-based deduplication
- Order service: OrderService for creating orders and invoices in
  Dolibarr with local mapping table storage
- Site language strings for all new views and messages
- Updated manifest to include site services folder

Resolves: #1, #2, #3, #4, #5, #6

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:38:01 -05:00
Jonathan Miller 7396dc3b86 chore(ci): use manifest.xml for platform detection, remove .moko-platform
Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:19:28 -05:00
Jonathan Miller 0dbf8c6891 chore: fix .mokogitea manifest and add .moko-platform
Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 17:15:00 -05:00
Jonathan Miller 4eaa742c8c chore(ci): migrate .gitea to .mokogitea and update workflows
Renamed .gitea/ to .mokogitea/ for MokoConsulting convention.
Updated release workflows with Joomla package type support.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:54:19 -05:00
Jonathan Miller 41a1efdd2c chore(ci): update release workflows with package type support
Synced from Template-Joomla: pre-release.yml and auto-release.yml now
handle Joomla package extensions (type="package" with sub-extensions).

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 15:53:11 -05:00
83 changed files with 7117 additions and 92 deletions
@@ -1,14 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
MokoStandards Repository Manifest
Template: Joomla Extension
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
-->
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
<identity>
<name>Template-Joomla</name>
<name>MokoDoliJoomShop</name>
<org>MokoConsulting</org>
<description>Template repository for Joomla extensions (plugins, modules, components, templates)</description>
<description>Joomla storefront component backed by Dolibarr products and invoicing</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
@@ -18,7 +13,7 @@
</governance>
<build>
<language>PHP</language>
<package-type>joomla-extension</package-type>
<package-type>joomla-component</package-type>
<entry-point>src/</entry-point>
</build>
</moko-platform>
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
@@ -58,7 +58,7 @@ jobs:
token: ${{ secrets.GA_TOKEN }}
fetch-depth: 0
- name: Setup MokoStandards tools
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
@@ -69,9 +69,9 @@ jobs:
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
git clone --depth 1 --branch main --quiet \
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/mokostandards-api
cd /tmp/mokostandards-api
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
@@ -79,16 +79,16 @@ jobs:
- name: Detect platform
id: platform
run: |
# Parse .manifest.xml via manifest_read.php — outputs all fields to GITHUB_OUTPUT
php /tmp/mokostandards-api/cli/manifest_read.php --path . --github-output 2>/dev/null || true
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
# Read platform from manifest.xml <platform> element; fallback to generic
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*//p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform detected: ${PLATFORM}"
# entry-point from manifest, find as fallback
MOD_FILE=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field entry-point 2>/dev/null)
[ -z "$MOD_FILE" ] && MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
# For packages: prefer pkg_*.xml in src/; fallback to any manifest
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
@@ -96,7 +96,7 @@ jobs:
- name: "Step 1: Read version from README.md"
id: version
run: |
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null)
VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path . 2>/dev/null)
if [ -z "$VERSION" ]; then
echo "No VERSION in README.md — skipping release"
echo "skip=true" >> "$GITHUB_OUTPUT"
@@ -161,6 +161,17 @@ jobs:
[ -n "$MANIFEST_VER" ] && sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
fi
# For packages: also bump version in all sub-extension manifests
if [ -d "src/packages" ]; then
for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null); do
SUB_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1)
if [ -n "$SUB_VER" ]; then
sed -i "s|<version>${SUB_VER}</version>|<version>${VERSION}</version>|" "$SUB_MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$SUB_MANIFEST"
echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}"
fi
done
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
@@ -187,7 +198,7 @@ jobs:
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
git push origin HEAD:dev 2>&1 || git push origin HEAD:main 2>&1
git push origin HEAD:main 2>&1
}
# Override version output for rest of pipeline
@@ -337,7 +348,7 @@ jobs:
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
php /tmp/mokostandards-api/cli/version_set_platform.php \
php /tmp/moko-platform-api/cli/version_set_platform.php \
--path . --version "$VERSION" --branch main
# -- STEP 4: Update version badges ----------------------------------------
@@ -364,7 +375,8 @@ jobs:
REPO="${{ github.repository }}"
# -- Parse extension metadata from XML manifest ----------------
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 2 -name "*.xml" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY
exit 0
@@ -561,7 +573,7 @@ jobs:
fi
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
@@ -621,7 +633,8 @@ jobs:
fi
# Find extension element name from manifest
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 2 -name "*.xml" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
[ -z "$MANIFEST" ] && exit 0
# Reuse element from Step 5, with same fallback chain
@@ -654,15 +667,39 @@ jobs:
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
# ZIP package
cd "$SOURCE_DIR"
zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES
cd ..
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
echo "=== Building Joomla PACKAGE (multi-extension) ==="
PKG_STAGE=$(mktemp -d)
# tar.gz package
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
--exclude='.ftpignore' --exclude='sftp-config*' \
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
# ZIP each sub-extension
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
[ ! -d "$ext_dir" ] && continue
SUB_NAME=$(basename "$ext_dir")
echo " Packaging sub-extension: ${SUB_NAME}"
(cd "$ext_dir" && zip -r "${PKG_STAGE}/${SUB_NAME}.zip" . -x $EXCLUDES)
done
# Copy package-level files (manifest, script, etc.)
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
[ -f "$f" ] && cp "$f" "${PKG_STAGE}/"
done
# Create ZIP and tar.gz from staged package
(cd "$PKG_STAGE" && zip -r "/tmp/${ZIP_NAME}" .)
tar -czf "/tmp/${TAR_NAME}" -C "$PKG_STAGE" .
rm -rf "$PKG_STAGE"
echo "Package contents built with sub-extension ZIPs"
else
# Standard extension: flat ZIP from src/
cd "$SOURCE_DIR"
zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES
cd ..
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
--exclude='.ftpignore' --exclude='sftp-config*' \
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
fi
ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
@@ -868,7 +905,7 @@ jobs:
BRANCH="${{ steps.version.outputs.branch }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
echo "$NOTES" > /tmp/release_notes.md
@@ -941,30 +978,25 @@ jobs:
done
echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY
# -- STEP 11: Sync dev branch with main + version bump ----------------------
- name: "Step 11: Merge main into dev (version bump lands on dev)"
# -- STEP 11: Reset dev branch from main ------------------------------------
- name: "Step 11: Delete and recreate dev branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
# Merge main into dev so dev has the release + version bump
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/merges" \
-d "{\"base\":\"dev\",\"head\":\"main\",\"message\":\"chore: sync main into dev after release [skip ci]\"}" \
2>/dev/null && echo "Merged main into dev"
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
# If dev doesn't exist, create it from main
if [ $? -ne 0 ]; then
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Created dev from main"
fi
echo "Dev branch synced with main (version bump on dev)" >> $GITHUB_STEP_SUMMARY
echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
@@ -4,7 +4,7 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Release
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.00.00
@@ -55,15 +55,14 @@ jobs:
- name: Detect platform
id: platform
run: |
# Parse .manifest.xml via manifest_read.php — outputs all fields to GITHUB_OUTPUT
php /tmp/mokostandards-api/cli/manifest_read.php --path . --github-output 2>/dev/null || true
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
tr -d '[:space:]')| tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
# entry-point from manifest, find as fallback
MOD_FILE=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field entry-point 2>/dev/null)
[ -z "$MOD_FILE" ] && MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
# For packages: prefer pkg_*.xml in src/; fallback to any manifest
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
@@ -120,6 +119,17 @@ jobs:
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
fi
# For packages: also bump version in all sub-extension manifests
if [ -d "src/packages" ]; then
for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null); do
SUB_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1)
if [ -n "$SUB_VER" ]; then
sed -i "s|<version>${SUB_VER}</version>|<version>${VERSION}</version>|" "$SUB_MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$SUB_MANIFEST"
echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}"
fi
done
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
@@ -191,17 +201,49 @@ jobs:
exit 1
fi
MANIFEST="${{ steps.meta.outputs.manifest }}"
EXT_TYPE=""
if [ -n "$MANIFEST" ]; then
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
fi
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
mkdir -p build/package
rsync -a \
--exclude='sftp-config*' \
--exclude='.ftpignore' \
--exclude='*.ppk' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='.env*' \
--exclude='*.local' \
--exclude='.build-trigger' \
"${SOURCE_DIR}/" build/package/
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
echo "=== Building Joomla PACKAGE (multi-extension) ==="
# 1) ZIP each sub-extension in src/packages/
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
[ ! -d "$ext_dir" ] && continue
EXT_NAME=$(basename "$ext_dir")
echo " Packaging sub-extension: ${EXT_NAME}"
cd "$ext_dir"
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
cd "$OLDPWD"
done
# 2) Copy package-level files (manifest, script, etc.)
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
[ -f "$f" ] && cp "$f" build/package/
done
echo "Package contents:"
ls -la build/package/
else
echo "=== Building standard Joomla extension ==="
rsync -a \
--exclude='sftp-config*' \
--exclude='.ftpignore' \
--exclude='*.ppk' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='.env*' \
--exclude='*.local' \
--exclude='.build-trigger' \
"${SOURCE_DIR}/" build/package/
fi
- name: Create ZIP
id: zip
+51 -6
View File
@@ -5,13 +5,58 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0-dev.1] - 2026-05-21
### Added
#### Core Storefront (Critical — Issues #1-6)
- Product catalog view with Dolibarr API integration, pagination, category filtering
- Product detail view with stock status, image gallery, Schema.org JSON-LD, add-to-cart
- Session-based shopping cart with DB persistence, quantity update, stock validation
- Cart merge on user login (guest → registered)
- Checkout flow with billing form, guest and registered modes
- Dolibarr order and invoice creation from cart data
- Customer sync service: Joomla user ↔ Dolibarr thirdparty mapping with email dedup
- Enhanced DolibarrClient with SSL verify, detailed connection test, permission checks
- Dashboard connection status with version display, permission indicators, troubleshooting hints
#### High Priority (Issues #7-12)
- Hierarchical product category navigation with sidebar tree and breadcrumbs
- Category landing pages with filtered product grid
- Stock/inventory display: In Stock / Low Stock / Out of Stock badges
- Configurable low-stock threshold and backorder support
- Tax calculation from Dolibarr `tva_tx` with grouped tax breakdown
- Configurable tax display mode (TTC, HT, or both)
- Product search controller with AJAX endpoint, text search, price range, sorting
- Order history (My Orders) for registered users with detail view
- Admin dashboard with product/order/customer counts, revenue metrics, recent orders
#### Medium Priority (Issues #13-19)
- SEF URL router for all views (clean URLs)
- Product image service with local caching, thumbnail support, placeholder fallback
- Email notification service: customer confirmation and admin notification on order
- Joomla menu item types for Products, Category, Product, Cart, Checkout, My Orders
- Responsive storefront CSS (mobile-first, sticky add-to-cart, touch-friendly cart)
- Product variant/attribute helper (Dolibarr combinations support)
- Admin orders management view with filters (status, date, search) and Dolibarr sync
#### Low Priority (Issues #20-27)
- Wishlist / Save for Later with DB persistence and guest merge on login
- Coupon/discount code validation against Dolibarr discount rules
- API response caching via Joomla cache framework with configurable TTL
- Shipping address management (address book, default address, CRUD)
- Joomla ACL integration (component-level permissions for products, orders, settings)
- Dolibarr webhook endpoint with event processing and log table
- Frontend invoice PDF download (streamed from Dolibarr with ownership check)
- Multi-language readiness (all strings via Joomla Text class)
#### Infrastructure
- Database schema: 6 tables (cart, orders, customers, wishlist, addresses, webhook_log)
- Component manifest with config fieldsets: Dolibarr, Shop, Performance, Webhooks
- Media folder with responsive CSS
- Full en-GB language files for admin and site
## [Unreleased]
### Added
- Initial component scaffold with Dolibarr REST API client
- Admin dashboard with connection status
- Admin views: products, orders, customers (placeholders)
- Site views: products catalog, product detail, cart, checkout (placeholders)
- Database schema for cart, orders, and customer mapping
- Component parameters for Dolibarr connection and shop settings
- Language files (en-GB)
@@ -0,0 +1,176 @@
/**
* MokoDoliJoomShop - Responsive Storefront Styles
* Mobile-first responsive layout for all storefront views.
*
* @package MokoDoliJoomShop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
/* ==========================================================================
Base / Mobile-first (320px+)
========================================================================== */
.com-mokodolijoomshop-products,
.com-mokodolijoomshop-product,
.com-mokodolijoomshop-cart,
.com-mokodolijoomshop-checkout,
.com-mokodolijoomshop-category,
.com-mokodolijoomshop-orders {
padding: 1rem 0;
}
/* Product cards */
.product-card {
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.product-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.product-card .card-title a {
color: inherit;
text-decoration: none;
}
.product-card .card-title a:hover {
color: var(--bs-primary);
}
/* Product gallery */
.product-gallery img {
width: 100%;
height: auto;
object-fit: cover;
}
/* Cart table mobile */
@media (max-width: 575.98px) {
.com-mokodolijoomshop-cart .table-responsive table {
font-size: 0.875rem;
}
.com-mokodolijoomshop-cart .table th,
.com-mokodolijoomshop-cart .table td {
padding: 0.5rem 0.25rem;
}
}
/* ==========================================================================
Tablet (576px+)
========================================================================== */
@media (min-width: 576px) {
.product-card .card-body {
min-height: 120px;
}
}
/* ==========================================================================
Desktop (992px+)
========================================================================== */
@media (min-width: 992px) {
.product-card .card-body {
min-height: 140px;
}
}
/* ==========================================================================
Mobile Product Detail - Sticky Add to Cart
========================================================================== */
@media (max-width: 767.98px) {
.com-mokodolijoomshop-product .input-group {
max-width: 100% !important;
}
.com-mokodolijoomshop-product form[action*="cart.add"] {
position: sticky;
bottom: 0;
background: var(--bs-body-bg, #fff);
padding: 0.75rem 0;
border-top: 1px solid var(--bs-border-color, #dee2e6);
z-index: 100;
}
/* Checkout form columns stack on mobile */
.com-mokodolijoomshop-checkout .row > .col-md-7,
.com-mokodolijoomshop-checkout .row > .col-md-5 {
margin-bottom: 1rem;
}
}
/* ==========================================================================
Category Sidebar
========================================================================== */
.com-mokodolijoomshop-category .list-group-item a {
color: inherit;
text-decoration: none;
}
.com-mokodolijoomshop-category .list-group-item a:hover {
color: var(--bs-primary);
}
.com-mokodolijoomshop-category .list-group-item.active a {
color: var(--bs-primary);
font-weight: 600;
}
/* Nested category lists */
.com-mokodolijoomshop-category .list-group .list-group {
margin-left: 1rem;
border: none;
}
/* ==========================================================================
Shop Categories Navigation (products view)
========================================================================== */
.shop-categories {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.25rem;
}
/* ==========================================================================
Touch-friendly Cart Controls
========================================================================== */
.com-mokodolijoomshop-cart input[type="number"] {
-webkit-appearance: none;
-moz-appearance: textfield;
text-align: center;
min-width: 50px;
}
.com-mokodolijoomshop-cart .btn-outline-danger {
min-width: 36px;
min-height: 36px;
}
/* ==========================================================================
Order Status Badges
========================================================================== */
.com-mokodolijoomshop-orders .badge {
text-transform: capitalize;
}
/* ==========================================================================
Print Styles
========================================================================== */
@media print {
.shop-categories,
.com-mokodolijoomshop-product form,
.com-mokodolijoomshop-cart .btn,
.pagination {
display: none !important;
}
}
@@ -27,5 +27,66 @@ COM_MOKODOLIJOOMSHOP_FIELD_TAX_ENABLED="Enable Tax"
COM_MOKODOLIJOOMSHOP_CONNECTION_OK="Dolibarr connection successful"
COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED="Dolibarr connection failed. Check URL and API key."
COM_MOKODOLIJOOMSHOP_DOLIBARR_VERSION="Dolibarr Version"
COM_MOKODOLIJOOMSHOP_PERMISSIONS="API Permissions"
COM_MOKODOLIJOOMSHOP_PERMISSION_READ="Products (read)"
COM_MOKODOLIJOOMSHOP_PERMISSION_WRITE="Third-parties (read/write)"
COM_MOKODOLIJOOMSHOP_TROUBLESHOOTING="Troubleshooting"
COM_MOKODOLIJOOMSHOP_QUICK_ACTIONS="Quick Actions"
COM_MOKODOLIJOOMSHOP_SYNC_PRODUCTS="Sync Products"
COM_MOKODOLIJOOMSHOP_SYNC_COMPLETE="Product sync complete: %d products updated"
COM_MOKODOLIJOOMSHOP_NO_PRODUCTS="No products found."
COM_MOKODOLIJOOMSHOP_PRODUCT_REF="Reference"
COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL="Label"
COM_MOKODOLIJOOMSHOP_PRODUCT_PRICE="Price"
COM_MOKODOLIJOOMSHOP_PRODUCT_STOCK="Stock"
COM_MOKODOLIJOOMSHOP_PRODUCT_STATUS="Status"
COM_MOKODOLIJOOMSHOP_PRODUCT_TOSELL="For Sale"
COM_MOKODOLIJOOMSHOP_PRODUCT_TOBUY="For Purchase"
COM_MOKODOLIJOOMSHOP_ORDER_REF="Order Ref"
COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF="Invoice Ref"
COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_HT="Total (excl. tax)"
COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC="Total (incl. tax)"
COM_MOKODOLIJOOMSHOP_ORDER_STATUS="Status"
COM_MOKODOLIJOOMSHOP_ORDER_DATE="Date"
COM_MOKODOLIJOOMSHOP_NO_ORDERS="No orders found."
COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME="Customer Name"
COM_MOKODOLIJOOMSHOP_CUSTOMER_EMAIL="Email"
COM_MOKODOLIJOOMSHOP_CUSTOMER_DOLIBARR_ID="Dolibarr ID"
COM_MOKODOLIJOOMSHOP_CUSTOMER_SYNCED="Last Synced"
COM_MOKODOLIJOOMSHOP_NO_CUSTOMERS="No customer mappings found."
COM_MOKODOLIJOOMSHOP_REVENUE="Revenue"
COM_MOKODOLIJOOMSHOP_REVENUE_TODAY="Today"
COM_MOKODOLIJOOMSHOP_REVENUE_WEEK="This Week"
COM_MOKODOLIJOOMSHOP_REVENUE_MONTH="This Month"
COM_MOKODOLIJOOMSHOP_RECENT_ORDERS="Recent Orders"
COM_MOKODOLIJOOMSHOP_FIELD_TAX_DISPLAY="Tax Display Mode"
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_TTC="Prices Include Tax (TTC)"
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_HT="Prices Exclude Tax (HT)"
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_BOTH="Show Both (TTC and HT)"
COM_MOKODOLIJOOMSHOP_FIELD_LOW_STOCK_THRESHOLD="Low Stock Threshold"
COM_MOKODOLIJOOMSHOP_FIELD_ALLOW_BACKORDER="Allow Backorders"
COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_SUBJECT="Order Confirmation — %s"
COM_MOKODOLIJOOMSHOP_EMAIL_ADMIN_ORDER_SUBJECT="New Order Received — %s"
COM_MOKODOLIJOOMSHOP_EMAIL_GREETING="Hello %s,"
COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_CONFIRMED="Thank you for your order! Here is your order summary:"
COM_MOKODOLIJOOMSHOP_EMAIL_NEW_ORDER="A new order has been placed."
COM_MOKODOLIJOOMSHOP_EMAIL_FOOTER="Thank you for shopping with %s."
COM_MOKODOLIJOOMSHOP_FIELDSET_PERFORMANCE="Performance"
COM_MOKODOLIJOOMSHOP_FIELD_CACHE_ENABLED="Enable API Caching"
COM_MOKODOLIJOOMSHOP_FIELD_CACHE_TTL="Cache Lifetime (seconds)"
COM_MOKODOLIJOOMSHOP_FIELDSET_WEBHOOKS="Webhooks"
COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET="Webhook Secret"
COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET_DESC="Shared secret for validating Dolibarr webhook requests."
COM_MOKODOLIJOOMSHOP_ACL_PRODUCTS_MANAGE="Manage Products"
COM_MOKODOLIJOOMSHOP_ACL_ORDERS_VIEW="View Orders"
COM_MOKODOLIJOOMSHOP_ACL_CUSTOMERS_MANAGE="Manage Customers"
COM_MOKODOLIJOOMSHOP_ACL_SETTINGS_MANAGE="Manage Settings"
+46
View File
@@ -45,6 +45,52 @@ CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_orders` (
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Wishlist items (save for later)
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_wishlist` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`session_id` varchar(255) NOT NULL DEFAULT '',
`dolibarr_product_id` int(11) NOT NULL,
`product_ref` varchar(128) NOT NULL DEFAULT '',
`product_label` varchar(255) NOT NULL DEFAULT '',
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_session` (`session_id`),
UNIQUE KEY `idx_user_product` (`user_id`, `dolibarr_product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Shipping addresses (user address book)
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_addresses` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`label` varchar(100) NOT NULL DEFAULT '',
`name` varchar(255) NOT NULL DEFAULT '',
`address` text NOT NULL,
`town` varchar(255) NOT NULL DEFAULT '',
`zip` varchar(20) NOT NULL DEFAULT '',
`country_code` varchar(5) NOT NULL DEFAULT '',
`phone` varchar(50) NOT NULL DEFAULT '',
`is_default` tinyint(1) NOT NULL DEFAULT 0,
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Webhook event log
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_webhook_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`event_type` varchar(100) NOT NULL,
`payload` text NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'received',
`message` varchar(500) NOT NULL DEFAULT '',
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_event_type` (`event_type`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Customer mapping (links Joomla users to Dolibarr thirdparties)
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_customers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
+3
View File
@@ -6,3 +6,6 @@
DROP TABLE IF EXISTS `#__mokodolijoomshop_cart`;
DROP TABLE IF EXISTS `#__mokodolijoomshop_orders`;
DROP TABLE IF EXISTS `#__mokodolijoomshop_customers`;
DROP TABLE IF EXISTS `#__mokodolijoomshop_wishlist`;
DROP TABLE IF EXISTS `#__mokodolijoomshop_addresses`;
DROP TABLE IF EXISTS `#__mokodolijoomshop_webhook_log`;
+111 -6
View File
@@ -13,6 +13,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Http\HttpFactory;
use Joomla\CMS\Log\Log;
use Joomla\Registry\Registry;
/**
* HTTP client for the Dolibarr REST API.
@@ -36,20 +37,28 @@ class DolibarrClient
*/
private string $apiKey;
/**
* @var bool Whether to verify SSL certificates.
* @since 1.0.0
*/
private bool $verifySSL;
/**
* Constructor. Reads connection settings from component params.
*
* @param string|null $baseUrl Override base URL.
* @param string|null $apiKey Override API key.
* @param string|null $baseUrl Override base URL.
* @param string|null $apiKey Override API key.
* @param bool|null $verifySSL Override SSL verification.
*
* @since 1.0.0
*/
public function __construct(?string $baseUrl = null, ?string $apiKey = null)
public function __construct(?string $baseUrl = null, ?string $apiKey = null, ?bool $verifySSL = null)
{
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
$this->verifySSL = $verifySSL ?? (bool) $params->get('dolibarr_verify_ssl', true);
}
/**
@@ -97,6 +106,20 @@ class DolibarrClient
return $this->request('PUT', $endpoint, [], $data);
}
/**
* Send a DELETE request to the Dolibarr API.
*
* @param string $endpoint API endpoint.
*
* @return array|null Decoded JSON response, or null on failure.
*
* @since 1.0.0
*/
public function delete(string $endpoint): ?array
{
return $this->request('DELETE', $endpoint);
}
/**
* Test the connection to the Dolibarr API.
*
@@ -111,6 +134,82 @@ class DolibarrClient
return $result !== null;
}
/**
* Detailed connection test returning status information.
*
* Checks connectivity, API version, and read/write permissions.
*
* @return array{ok: bool, version: string, permissions: array, error: string, hint: string}
*
* @since 1.0.0
*/
public function testConnectionDetailed(): array
{
$result = [
'ok' => false,
'version' => '',
'permissions' => ['read' => false, 'write' => false],
'error' => '',
'hint' => '',
];
if (empty($this->baseUrl)) {
$result['error'] = 'Dolibarr URL is not configured.';
$result['hint'] = 'Set the Dolibarr URL in component options (e.g., https://erp.example.com).';
return $result;
}
if (empty($this->apiKey)) {
$result['error'] = 'API key is not configured.';
$result['hint'] = 'Generate an API key in Dolibarr: Setup > Security > API and set it in component options.';
return $result;
}
// Test basic connectivity
$status = $this->get('/status');
if ($status === null) {
$result['error'] = 'Cannot reach Dolibarr API.';
$result['hint'] = 'Verify the URL is correct and the Dolibarr API module is enabled. '
. 'Check: Home > Setup > Modules > Web Services API REST.';
return $result;
}
$result['ok'] = true;
$result['version'] = $status['success']['dolibarr_version'] ?? ($status['dolibarr_version'] ?? 'unknown');
// Test read access (list products, limit 1)
$readTest = $this->get('/products', ['limit' => 1]);
$result['permissions']['read'] = ($readTest !== null);
// Test write access by checking thirdparties access (non-destructive)
$writeTest = $this->get('/thirdparties', ['limit' => 1]);
$result['permissions']['write'] = ($writeTest !== null);
if (!$result['permissions']['read']) {
$result['ok'] = false;
$result['error'] = 'API key lacks read permission for products.';
$result['hint'] = 'Ensure the API user has permissions: Products (read) and Third-parties (read/write).';
}
return $result;
}
/**
* Check if the client is configured (has URL and key set).
*
* @return bool
*
* @since 1.0.0
*/
public function isConfigured(): bool
{
return !empty($this->baseUrl) && !empty($this->apiKey);
}
/**
* Execute an HTTP request against the Dolibarr REST API.
*
@@ -147,7 +246,13 @@ class DolibarrClient
try
{
$http = HttpFactory::getHttp();
$options = new Registry();
$options->set('transport.curl', [
CURLOPT_SSL_VERIFYPEER => $this->verifySSL,
CURLOPT_SSL_VERIFYHOST => $this->verifySSL ? 2 : 0,
]);
$http = HttpFactory::getHttp($options);
$jsonBody = !empty($body) ? json_encode($body) : null;
switch (strtoupper($method))
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Dashboard model — provides metrics and sync data for the admin dashboard.
*
* @since 1.0.0
*/
class DashboardModel extends BaseDatabaseModel
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @param array $config Configuration array.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
parent::__construct($config);
$this->client = new DolibarrClient();
}
/**
* Get product count from Dolibarr.
*
* @return int
*
* @since 1.0.0
*/
public function getProductCount(): int
{
$products = $this->client->get('/products', ['limit' => 0]);
return $products !== null ? \count($products) : 0;
}
/**
* Get local order count.
*
* @return int
*
* @since 1.0.0
*/
public function getOrderCount(): int
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('COUNT(*)')
->from($db->quoteName('#__mokodolijoomshop_orders'));
$db->setQuery($query);
return (int) $db->loadResult();
}
/**
* Get customer mapping count.
*
* @return int
*
* @since 1.0.0
*/
public function getCustomerCount(): int
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('COUNT(*)')
->from($db->quoteName('#__mokodolijoomshop_customers'));
$db->setQuery($query);
return (int) $db->loadResult();
}
/**
* Get recent orders from local table.
*
* @param int $limit Number of recent orders.
*
* @return array
*
* @since 1.0.0
*/
public function getRecentOrders(int $limit = 10): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('*')
->from($db->quoteName('#__mokodolijoomshop_orders'))
->order($db->quoteName('created') . ' DESC');
$db->setQuery($query, 0, $limit);
return $db->loadAssocList() ?: [];
}
/**
* Get revenue metrics.
*
* @return array{today: float, week: float, month: float}
*
* @since 1.0.0
*/
public function getRevenue(): array
{
$db = $this->getDatabase();
$now = Factory::getDate();
$today = $now->format('Y-m-d');
$week = Factory::getDate('-7 days')->format('Y-m-d');
$month = Factory::getDate('-30 days')->format('Y-m-d');
$revenue = ['today' => 0.0, 'week' => 0.0, 'month' => 0.0];
// Today
$query = $db->getQuery(true);
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
->from($db->quoteName('#__mokodolijoomshop_orders'))
->where($db->quoteName('created') . ' >= ' . $db->quote($today . ' 00:00:00'));
$db->setQuery($query);
$revenue['today'] = (float) $db->loadResult();
// Week
$query = $db->getQuery(true);
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
->from($db->quoteName('#__mokodolijoomshop_orders'))
->where($db->quoteName('created') . ' >= ' . $db->quote($week . ' 00:00:00'));
$db->setQuery($query);
$revenue['week'] = (float) $db->loadResult();
// Month
$query = $db->getQuery(true);
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
->from($db->quoteName('#__mokodolijoomshop_orders'))
->where($db->quoteName('created') . ' >= ' . $db->quote($month . ' 00:00:00'));
$db->setQuery($query);
$revenue['month'] = (float) $db->loadResult();
return $revenue;
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Admin orders model — lists and manages orders from local mapping table.
*
* @since 1.0.0
*/
class OrdersModel extends BaseDatabaseModel
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @param array $config Configuration array.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
parent::__construct($config);
$this->client = new DolibarrClient();
}
/**
* Get all orders with optional filters.
*
* @return array
*
* @since 1.0.0
*/
public function getItems(): array
{
$app = Factory::getApplication();
$db = $this->getDatabase();
$query = $db->getQuery(true);
$status = $app->input->getString('filter_status', '');
$dateFrom = $app->input->getString('filter_date_from', '');
$dateTo = $app->input->getString('filter_date_to', '');
$search = $app->input->getString('filter_search', '');
$query->select('o.*')
->from($db->quoteName('#__mokodolijoomshop_orders', 'o'))
->order($db->quoteName('o.created') . ' DESC');
if (!empty($status))
{
$query->where($db->quoteName('o.status') . ' = ' . $db->quote($status));
}
if (!empty($dateFrom))
{
$query->where($db->quoteName('o.created') . ' >= ' . $db->quote($dateFrom . ' 00:00:00'));
}
if (!empty($dateTo))
{
$query->where($db->quoteName('o.created') . ' <= ' . $db->quote($dateTo . ' 23:59:59'));
}
if (!empty($search))
{
$searchQuoted = $db->quote('%' . $search . '%');
$query->where(
'(' . $db->quoteName('o.order_ref') . ' LIKE ' . $searchQuoted
. ' OR ' . $db->quoteName('o.invoice_ref') . ' LIKE ' . $searchQuoted . ')'
);
}
$db->setQuery($query);
$orders = $db->loadAssocList() ?: [];
// Enrich with user names
foreach ($orders as &$order)
{
if ((int) $order['user_id'] > 0)
{
$userQuery = $db->getQuery(true);
$userQuery->select($db->quoteName('name'))
->from($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . (int) $order['user_id']);
$db->setQuery($userQuery);
$order['customer_name'] = $db->loadResult() ?: 'User #' . $order['user_id'];
}
else
{
$order['customer_name'] = 'Guest';
}
}
unset($order);
return $orders;
}
/**
* Sync order status from Dolibarr for a specific order.
*
* @param int $localOrderId Local order table ID.
*
* @return string|null Updated status, or null on failure.
*
* @since 1.0.0
*/
public function syncOrderStatus(int $localOrderId): ?string
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('*')
->from($db->quoteName('#__mokodolijoomshop_orders'))
->where($db->quoteName('id') . ' = ' . $localOrderId);
$db->setQuery($query);
$local = $db->loadAssoc();
if (empty($local) || empty($local['dolibarr_order_id']))
{
return null;
}
$order = $this->client->get('/orders/' . (int) $local['dolibarr_order_id']);
if ($order === null)
{
return null;
}
$statusMap = [
-1 => 'cancelled',
0 => 'draft',
1 => 'validated',
2 => 'shipped',
3 => 'delivered',
];
$statusCode = (int) ($order['statut'] ?? $order['status'] ?? 0);
$newStatus = $statusMap[$statusCode] ?? 'unknown';
// Update local status
$update = $db->getQuery(true);
$update->update($db->quoteName('#__mokodolijoomshop_orders'))
->set($db->quoteName('status') . ' = ' . $db->quote($newStatus))
->where($db->quoteName('id') . ' = ' . $localOrderId);
$db->setQuery($update);
$db->execute();
return $newStatus;
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Cache\CacheControllerFactoryInterface;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
/**
* API response cache service using Joomla's cache framework.
*
* Caches Dolibarr API responses to reduce load and improve performance.
*
* @since 1.0.0
*/
class CacheService
{
/**
* @var string Cache group name.
* @since 1.0.0
*/
private const GROUP = 'com_mokodolijoomshop';
/**
* Get a cached value or execute the callback and cache the result.
*
* @param string $key Cache key.
* @param callable $callback Function to call if cache miss.
* @param int|null $ttl Time-to-live in seconds (null = use default).
*
* @return mixed
*
* @since 1.0.0
*/
public static function remember(string $key, callable $callback, ?int $ttl = null)
{
if (!self::isEnabled())
{
return $callback();
}
$cache = self::getCache($ttl);
$id = md5($key);
$result = $cache->get($id, self::GROUP);
if ($result !== false)
{
return $result;
}
$result = $callback();
$cache->store($result, $id, self::GROUP);
return $result;
}
/**
* Invalidate a specific cache key.
*
* @param string $key Cache key to invalidate.
*
* @return void
*
* @since 1.0.0
*/
public static function forget(string $key): void
{
$cache = self::getCache();
$cache->remove(md5($key), self::GROUP);
}
/**
* Clear all component cache (used during manual sync).
*
* @return void
*
* @since 1.0.0
*/
public static function flush(): void
{
$cache = self::getCache();
$cache->clean(self::GROUP);
}
/**
* Check if caching is enabled.
*
* @return bool
*
* @since 1.0.0
*/
public static function isEnabled(): bool
{
$params = ComponentHelper::getParams('com_mokodolijoomshop');
return (bool) $params->get('cache_enabled', true);
}
/**
* Get the default TTL in seconds.
*
* @return int
*
* @since 1.0.0
*/
public static function getDefaultTtl(): int
{
$params = ComponentHelper::getParams('com_mokodolijoomshop');
return (int) $params->get('cache_ttl', 900); // 15 minutes default
}
/**
* Get a Joomla cache controller.
*
* @param int|null $ttl TTL override in seconds.
*
* @return \Joomla\CMS\Cache\CacheController
*
* @since 1.0.0
*/
private static function getCache(?int $ttl = null)
{
$options = [
'defaultgroup' => self::GROUP,
'caching' => true,
'lifetime' => ($ttl ?? self::getDefaultTtl()) / 60,
];
return Factory::getContainer()
->get(CacheControllerFactoryInterface::class)
->createCacheController('output', $options);
}
}
@@ -0,0 +1,259 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\User\User;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Syncs Joomla users to Dolibarr thirdparties (customers).
*
* @since 1.0.0
*/
class CustomerSyncService
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @param DolibarrClient|null $client Optional client override.
*
* @since 1.0.0
*/
public function __construct(?DolibarrClient $client = null)
{
$this->client = $client ?? new DolibarrClient();
}
/**
* Get or create a Dolibarr thirdparty for the given Joomla user.
*
* Checks the local mapping table first, then searches Dolibarr by email,
* and finally creates a new thirdparty if none exists.
*
* @param int $userId Joomla user ID.
*
* @return int|null Dolibarr thirdparty ID, or null on failure.
*
* @since 1.0.0
*/
public function getOrCreateThirdparty(int $userId): ?int
{
// Check local mapping first
$existingId = $this->getLocalMapping($userId);
if ($existingId !== null)
{
return $existingId;
}
$user = Factory::getContainer()->get(\Joomla\CMS\User\UserFactoryInterface::class)->loadUserById($userId);
if ($user->guest || empty($user->email))
{
return null;
}
// Search Dolibarr by email to avoid duplicates
$existing = $this->findThirdpartyByEmail($user->email);
if ($existing !== null)
{
$this->saveMapping($userId, $existing);
return $existing;
}
// Create new thirdparty in Dolibarr
$thirdpartyId = $this->createThirdparty($user);
if ($thirdpartyId !== null)
{
$this->saveMapping($userId, $thirdpartyId);
}
return $thirdpartyId;
}
/**
* Create a guest customer in Dolibarr (no Joomla user mapping).
*
* @param string $name Customer name.
* @param string $email Customer email.
* @param string $address Billing address.
* @param string $town City.
* @param string $zip Postal code.
* @param string $phone Phone number.
*
* @return int|null Dolibarr thirdparty ID.
*
* @since 1.0.0
*/
public function createGuestCustomer(
string $name,
string $email,
string $address = '',
string $town = '',
string $zip = '',
string $phone = ''
): ?int {
// Check if already exists by email
$existing = $this->findThirdpartyByEmail($email);
if ($existing !== null)
{
return $existing;
}
$data = [
'name' => $name,
'email' => $email,
'client' => 1,
'code_client' => '-1',
'address' => $address,
'town' => $town,
'zip' => $zip,
'phone' => $phone,
];
$result = $this->client->post('/thirdparties', $data);
if ($result === null)
{
Log::add('CustomerSyncService: Failed to create guest thirdparty for ' . $email, Log::ERROR, 'com_mokodolijoomshop');
return null;
}
return (int) $result;
}
/**
* Get the local mapping for a Joomla user.
*
* @param int $userId Joomla user ID.
*
* @return int|null Dolibarr thirdparty ID, or null if not mapped.
*
* @since 1.0.0
*/
public function getLocalMapping(int $userId): ?int
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true);
$query->select($db->quoteName('dolibarr_thirdparty_id'))
->from($db->quoteName('#__mokodolijoomshop_customers'))
->where($db->quoteName('user_id') . ' = ' . $userId);
$db->setQuery($query);
$result = $db->loadResult();
return $result !== null ? (int) $result : null;
}
/**
* Search Dolibarr for a thirdparty matching the given email.
*
* @param string $email Email address to search.
*
* @return int|null Thirdparty ID or null.
*
* @since 1.0.0
*/
private function findThirdpartyByEmail(string $email): ?int
{
$results = $this->client->get('/thirdparties', [
'sortfield' => 't.rowid',
'sortorder' => 'ASC',
'limit' => 1,
'sqlfilters' => "(t.email:=:'" . addslashes($email) . "')",
]);
if (!empty($results) && isset($results[0]['id']))
{
return (int) $results[0]['id'];
}
return null;
}
/**
* Create a Dolibarr thirdparty from a Joomla user.
*
* @param User $user Joomla user object.
*
* @return int|null Created thirdparty ID.
*
* @since 1.0.0
*/
private function createThirdparty(User $user): ?int
{
$data = [
'name' => $user->name,
'email' => $user->email,
'client' => 1,
'code_client' => '-1',
];
$result = $this->client->post('/thirdparties', $data);
if ($result === null)
{
Log::add(
'CustomerSyncService: Failed to create thirdparty for user ' . $user->id,
Log::ERROR,
'com_mokodolijoomshop'
);
return null;
}
return (int) $result;
}
/**
* Save a user ↔ thirdparty mapping in the local database.
*
* @param int $userId Joomla user ID.
* @param int $thirdpartyId Dolibarr thirdparty ID.
*
* @return bool
*
* @since 1.0.0
*/
private function saveMapping(int $userId, int $thirdpartyId): bool
{
$db = Factory::getContainer()->get('DatabaseDriver');
$table = new \Moko\Component\MokoDoliJoomShop\Administrator\Table\CustomerTable($db);
$data = [
'user_id' => $userId,
'dolibarr_thirdparty_id' => $thirdpartyId,
'synced_at' => Factory::getDate()->toSql(),
];
$table->bind($data);
if (!$table->check())
{
return false;
}
return $table->store();
}
}
+244
View File
@@ -0,0 +1,244 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Mail\MailerFactoryInterface;
/**
* Email notification service for order events.
*
* Sends customer confirmation and admin notification emails
* using Joomla's mail transport system.
*
* @since 1.0.0
*/
class EmailService
{
/**
* Send order confirmation email to the customer.
*
* @param string $customerEmail Customer email address.
* @param string $customerName Customer name.
* @param array $orderData Order result data (order_ref, invoice_ref).
* @param array $cartItems Cart items at time of order.
* @param array $totals Cart totals (subtotal, tax, total).
*
* @return bool True if sent successfully.
*
* @since 1.0.0
*/
public function sendCustomerConfirmation(
string $customerEmail,
string $customerName,
array $orderData,
array $cartItems,
array $totals
): bool {
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$currency = $params->get('currency', 'USD');
$siteName = Factory::getApplication()->get('sitename');
$subject = Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_SUBJECT', $orderData['order_ref'] ?? '');
$body = $this->buildCustomerEmailBody(
$customerName,
$orderData,
$cartItems,
$totals,
$currency,
$siteName
);
return $this->sendMail($customerEmail, $subject, $body);
}
/**
* Send order notification email to the admin.
*
* @param array $orderData Order result data.
* @param array $cartItems Cart items.
* @param array $totals Cart totals.
* @param string $customerName Customer name.
* @param string $customerEmail Customer email.
*
* @return bool True if sent successfully.
*
* @since 1.0.0
*/
public function sendAdminNotification(
array $orderData,
array $cartItems,
array $totals,
string $customerName,
string $customerEmail
): bool {
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$currency = $params->get('currency', 'USD');
$adminMail = Factory::getApplication()->get('mailfrom');
$subject = Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_ADMIN_ORDER_SUBJECT', $orderData['order_ref'] ?? '');
$body = $this->buildAdminEmailBody(
$orderData,
$cartItems,
$totals,
$currency,
$customerName,
$customerEmail
);
return $this->sendMail($adminMail, $subject, $body);
}
/**
* Build the customer confirmation email HTML body.
*
* @param string $name Customer name.
* @param array $order Order data.
* @param array $items Cart items.
* @param array $totals Totals.
* @param string $currency Currency code.
* @param string $siteName Site name.
*
* @return string HTML email body.
*
* @since 1.0.0
*/
private function buildCustomerEmailBody(
string $name,
array $order,
array $items,
array $totals,
string $currency,
string $siteName
): string {
$orderRef = htmlspecialchars($order['order_ref'] ?? '');
$invoiceRef = htmlspecialchars($order['invoice_ref'] ?? '');
$html = '<html><body style="font-family: Arial, sans-serif; line-height: 1.6;">';
$html .= '<h2>' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_GREETING', htmlspecialchars($name)) . '</h2>';
$html .= '<p>' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_CONFIRMED') . '</p>';
$html .= '<table style="border-collapse:collapse; width:100%; margin:20px 0;">';
$html .= '<tr><td><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':</strong></td><td>' . $orderRef . '</td></tr>';
if ($invoiceRef)
{
$html .= '<tr><td><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF') . ':</strong></td><td>' . $invoiceRef . '</td></tr>';
}
$html .= '</table>';
// Items table
$html .= '<table style="border-collapse:collapse; width:100%; margin:20px 0; border:1px solid #ddd;">';
$html .= '<thead><tr style="background:#f5f5f5;">';
$html .= '<th style="padding:8px; text-align:left; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL') . '</th>';
$html .= '<th style="padding:8px; text-align:center; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . '</th>';
$html .= '<th style="padding:8px; text-align:right; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '</th>';
$html .= '</tr></thead><tbody>';
foreach ($items as $item)
{
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
$html .= '<tr>';
$html .= '<td style="padding:8px; border:1px solid #ddd;">' . htmlspecialchars($item['product_label']) . '</td>';
$html .= '<td style="padding:8px; text-align:center; border:1px solid #ddd;">' . (int) $item['quantity'] . '</td>';
$html .= '<td style="padding:8px; text-align:right; border:1px solid #ddd;">' . number_format($lineTotal, 2) . ' ' . $currency . '</td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
// Totals
$html .= '<table style="width:300px; margin-left:auto;">';
$html .= '<tr><td>' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '</td><td style="text-align:right;">' . number_format($totals['subtotal'], 2) . ' ' . $currency . '</td></tr>';
if ($totals['tax'] > 0)
{
$html .= '<tr><td>' . Text::_('COM_MOKODOLIJOOMSHOP_TAX') . '</td><td style="text-align:right;">' . number_format($totals['tax'], 2) . ' ' . $currency . '</td></tr>';
}
$html .= '<tr style="font-weight:bold; font-size:1.2em;"><td>' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . '</td><td style="text-align:right;">' . number_format($totals['total'], 2) . ' ' . $currency . '</td></tr>';
$html .= '</table>';
$html .= '<p style="margin-top:30px; color:#666;">' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_FOOTER', htmlspecialchars($siteName)) . '</p>';
$html .= '</body></html>';
return $html;
}
/**
* Build the admin notification email body.
*
* @param array $order Order data.
* @param array $items Cart items.
* @param array $totals Totals.
* @param string $currency Currency.
* @param string $customerName Customer name.
* @param string $customerEmail Customer email.
*
* @return string HTML body.
*
* @since 1.0.0
*/
private function buildAdminEmailBody(
array $order,
array $items,
array $totals,
string $currency,
string $customerName,
string $customerEmail
): string {
$html = '<html><body style="font-family: Arial, sans-serif;">';
$html .= '<h2>' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_NEW_ORDER') . '</h2>';
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':</strong> ' . htmlspecialchars($order['order_ref'] ?? '') . '</p>';
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME') . ':</strong> ' . htmlspecialchars($customerName) . ' (' . htmlspecialchars($customerEmail) . ')</p>';
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . ':</strong> ' . number_format($totals['total'], 2) . ' ' . $currency . '</p>';
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . ':</strong> ' . \count($items) . ' item(s)</p>';
$html .= '</body></html>';
return $html;
}
/**
* Send an HTML email using Joomla's mail system.
*
* @param string $to Recipient email.
* @param string $subject Email subject.
* @param string $body HTML body.
*
* @return bool
*
* @since 1.0.0
*/
private function sendMail(string $to, string $subject, string $body): bool
{
try
{
$mailer = Factory::getContainer()->get(MailerFactoryInterface::class)->createMailer();
$mailer->addRecipient($to);
$mailer->setSubject($subject);
$mailer->setBody($body);
$mailer->isHtml(true);
return $mailer->Send();
}
catch (\Exception $e)
{
Log::add('EmailService: ' . $e->getMessage(), Log::ERROR, 'com_mokodolijoomshop');
return false;
}
}
}
+239
View File
@@ -0,0 +1,239 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Uri\Uri;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Product image service — fetches, caches, and serves product images.
*
* Images are stored in: /media/com_mokodolijoomshop/images/products/{product_id}/
*
* @since 1.0.0
*/
class ImageService
{
/**
* @var string Base path for cached images.
* @since 1.0.0
*/
private string $basePath;
/**
* @var string Base URL for cached images.
* @since 1.0.0
*/
private string $baseUrl;
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* @var string Placeholder image path.
* @since 1.0.0
*/
private const PLACEHOLDER = 'media/com_mokodolijoomshop/images/placeholder.png';
/**
* Constructor.
*
* @param DolibarrClient|null $client Optional client override.
*
* @since 1.0.0
*/
public function __construct(?DolibarrClient $client = null)
{
$this->client = $client ?? new DolibarrClient();
$this->basePath = JPATH_ROOT . '/media/com_mokodolijoomshop/images/products';
$this->baseUrl = Uri::root() . 'media/com_mokodolijoomshop/images/products';
}
/**
* Get image URLs for a product, fetching from Dolibarr if not cached.
*
* @param int $productId Dolibarr product ID.
*
* @return array Array of image URLs (local cached paths).
*
* @since 1.0.0
*/
public function getProductImages(int $productId): array
{
$productDir = $this->basePath . '/' . $productId;
// Check cache first
if (is_dir($productDir))
{
$files = Folder::files($productDir, '\.(jpe?g|png|gif|webp)$', false, true);
if (!empty($files))
{
return array_map(function ($file) use ($productId) {
return $this->baseUrl . '/' . $productId . '/' . basename($file);
}, $files);
}
}
// Fetch from Dolibarr
return $this->fetchAndCache($productId);
}
/**
* Get a single thumbnail URL for list views.
*
* @param int $productId Dolibarr product ID.
*
* @return string Image URL or placeholder.
*
* @since 1.0.0
*/
public function getThumbnail(int $productId): string
{
$images = $this->getProductImages($productId);
if (empty($images))
{
return Uri::root() . self::PLACEHOLDER;
}
// Return first image as thumbnail
return $images[0];
}
/**
* Get the placeholder image URL.
*
* @return string
*
* @since 1.0.0
*/
public function getPlaceholder(): string
{
return Uri::root() . self::PLACEHOLDER;
}
/**
* Invalidate the image cache for a product (used during sync).
*
* @param int $productId Dolibarr product ID.
*
* @return bool
*
* @since 1.0.0
*/
public function invalidateCache(int $productId): bool
{
$productDir = $this->basePath . '/' . $productId;
if (is_dir($productDir))
{
return Folder::delete($productDir);
}
return true;
}
/**
* Invalidate all cached images.
*
* @return bool
*
* @since 1.0.0
*/
public function invalidateAll(): bool
{
if (is_dir($this->basePath))
{
return Folder::delete($this->basePath) && Folder::create($this->basePath);
}
return true;
}
/**
* Fetch product images from Dolibarr and cache them locally.
*
* @param int $productId Dolibarr product ID.
*
* @return array Array of cached image URLs.
*
* @since 1.0.0
*/
private function fetchAndCache(int $productId): array
{
$docs = $this->client->get('/documents', [
'modulepart' => 'product',
'id' => $productId,
]);
if (empty($docs) || !\is_array($docs))
{
return [];
}
$productDir = $this->basePath . '/' . $productId;
if (!is_dir($productDir))
{
Folder::create($productDir);
}
$urls = [];
foreach ($docs as $doc)
{
$filename = $doc['name'] ?? basename($doc['relativename'] ?? '');
if (!preg_match('/\.(jpe?g|png|gif|webp)$/i', $filename))
{
continue;
}
// Download the file content
$content = null;
if (!empty($doc['content']))
{
// Base64 encoded content
$content = base64_decode($doc['content']);
}
elseif (!empty($doc['fullname']))
{
// Fetch via documents/download endpoint
$download = $this->client->get('/documents/download', [
'modulepart' => 'product',
'original_file' => $doc['relativename'] ?? $filename,
]);
if (!empty($download['content']))
{
$content = base64_decode($download['content']);
}
}
if ($content !== null)
{
$localPath = $productDir . '/' . $filename;
File::write($localPath, $content);
$urls[] = $this->baseUrl . '/' . $productId . '/' . $filename;
}
}
return $urls;
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
use Moko\Component\MokoDoliJoomShop\Administrator\Table\OrderTable;
/**
* Creates orders and invoices in Dolibarr from cart data.
*
* @since 1.0.0
*/
class OrderService
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @param DolibarrClient|null $client Optional client override.
*
* @since 1.0.0
*/
public function __construct(?DolibarrClient $client = null)
{
$this->client = $client ?? new DolibarrClient();
}
/**
* Create an order in Dolibarr from cart items.
*
* @param int $thirdpartyId Dolibarr thirdparty (customer) ID.
* @param array $cartItems Cart items array from CartModel::getItems().
* @param array $metadata Additional order metadata (note_public, note_private, etc.).
*
* @return array|null Array with order data, or null on failure.
*
* @since 1.0.0
*/
public function createOrder(int $thirdpartyId, array $cartItems, array $metadata = []): ?array
{
if (empty($cartItems))
{
return null;
}
// Build line items
$lines = [];
foreach ($cartItems as $item)
{
$lines[] = [
'fk_product' => (int) $item['dolibarr_product_id'],
'qty' => (int) $item['quantity'],
'subprice' => (float) $item['unit_price'],
'tva_tx' => (float) $item['tax_rate'],
'product_type' => 0,
'desc' => $item['product_label'] ?? '',
];
}
$orderData = [
'socid' => $thirdpartyId,
'date' => date('Y-m-d'),
'lines' => $lines,
'note_public' => $metadata['note_public'] ?? '',
'note_private' => $metadata['note_private'] ?? '',
];
$result = $this->client->post('/orders', $orderData);
if ($result === null)
{
Log::add('OrderService: Failed to create order for thirdparty ' . $thirdpartyId, Log::ERROR, 'com_mokodolijoomshop');
return null;
}
$orderId = (int) $result;
// Fetch created order details
$order = $this->client->get('/orders/' . $orderId);
if ($order === null)
{
return ['id' => $orderId, 'ref' => ''];
}
// Validate (set to status 1 = validated)
$this->client->post('/orders/' . $orderId . '/validate', []);
return $order;
}
/**
* Create an invoice from a Dolibarr order.
*
* @param int $orderId Dolibarr order ID.
*
* @return array|null Invoice data, or null on failure.
*
* @since 1.0.0
*/
public function createInvoiceFromOrder(int $orderId): ?array
{
$invoiceData = [
'socid' => 0,
];
// Use createfromorder endpoint
$result = $this->client->post('/invoices/createfromorder/' . $orderId, $invoiceData);
if ($result === null)
{
// Fallback: create invoice manually from order data
$order = $this->client->get('/orders/' . $orderId);
if ($order === null)
{
Log::add('OrderService: Failed to create invoice from order ' . $orderId, Log::ERROR, 'com_mokodolijoomshop');
return null;
}
$lines = [];
foreach ($order['lines'] ?? [] as $line)
{
$lines[] = [
'fk_product' => (int) ($line['fk_product'] ?? 0),
'qty' => (float) ($line['qty'] ?? 1),
'subprice' => (float) ($line['subprice'] ?? 0),
'tva_tx' => (float) ($line['tva_tx'] ?? 0),
'product_type' => (int) ($line['product_type'] ?? 0),
'desc' => $line['desc'] ?? '',
];
}
$invoicePayload = [
'socid' => (int) ($order['socid'] ?? 0),
'date' => date('Y-m-d'),
'lines' => $lines,
'linked_objects' => ['commande' => $orderId],
];
$result = $this->client->post('/invoices', $invoicePayload);
if ($result === null)
{
return null;
}
}
$invoiceId = (int) $result;
$this->client->post('/invoices/' . $invoiceId . '/validate', []);
return $this->client->get('/invoices/' . $invoiceId);
}
/**
* Save order mapping to local database.
*
* @param int $userId Joomla user ID (0 for guest).
* @param int $orderId Dolibarr order ID.
* @param int $invoiceId Dolibarr invoice ID.
* @param int $thirdpartyId Dolibarr thirdparty ID.
* @param string $orderRef Order reference string.
* @param string $invoiceRef Invoice reference string.
* @param float $totalHT Total excl. tax.
* @param float $totalTTC Total incl. tax.
*
* @return bool
*
* @since 1.0.0
*/
public function saveOrderMapping(
int $userId,
int $orderId,
int $invoiceId,
int $thirdpartyId,
string $orderRef,
string $invoiceRef,
float $totalHT,
float $totalTTC
): bool {
$db = Factory::getContainer()->get('DatabaseDriver');
$table = new OrderTable($db);
$data = [
'user_id' => $userId,
'dolibarr_order_id' => $orderId,
'dolibarr_invoice_id' => $invoiceId,
'dolibarr_thirdparty_id' => $thirdpartyId,
'order_ref' => $orderRef,
'invoice_ref' => $invoiceRef,
'total_ht' => $totalHT,
'total_ttc' => $totalTTC,
'status' => 'confirmed',
];
$table->bind($data);
if (!$table->check())
{
return false;
}
return $table->store();
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
/**
* Webhook service — receives and processes Dolibarr webhook events.
*
* Endpoint: /api/mokodolijoomshop/webhook
*
* @since 1.0.0
*/
class WebhookService
{
/**
* Validate the webhook secret.
*
* @param string $providedSecret Secret from request header.
*
* @return bool
*
* @since 1.0.0
*/
public function validateSecret(string $providedSecret): bool
{
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$expectedSecret = $params->get('webhook_secret', '');
if (empty($expectedSecret))
{
return false;
}
return hash_equals($expectedSecret, $providedSecret);
}
/**
* Process an incoming webhook event.
*
* @param string $eventType Event type (e.g., 'PRODUCT_CREATE', 'ORDER_UPDATE').
* @param array $payload Event payload data.
*
* @return bool True if processed successfully.
*
* @since 1.0.0
*/
public function processEvent(string $eventType, array $payload): bool
{
$this->logEvent($eventType, $payload, 'processing');
try
{
switch ($eventType)
{
case 'PRODUCT_CREATE':
case 'PRODUCT_MODIFY':
$this->handleProductChange($payload);
break;
case 'PRODUCT_DELETE':
$this->handleProductDelete($payload);
break;
case 'ORDER_VALIDATE':
case 'ORDER_MODIFY':
case 'ORDER_CLOSE':
case 'ORDER_CANCEL':
$this->handleOrderStatusChange($payload);
break;
case 'PAYMENT_CUSTOMER_CREATE':
$this->handlePaymentReceived($payload);
break;
default:
$this->logEvent($eventType, $payload, 'ignored', 'Unknown event type');
return true;
}
$this->logEvent($eventType, $payload, 'success');
return true;
}
catch (\Exception $e)
{
$this->logEvent($eventType, $payload, 'error', $e->getMessage());
Log::add('WebhookService: ' . $e->getMessage(), Log::ERROR, 'com_mokodolijoomshop');
return false;
}
}
/**
* Handle product create/modify — invalidate image cache.
*
* @param array $payload Event payload.
*
* @return void
*
* @since 1.0.0
*/
private function handleProductChange(array $payload): void
{
$productId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
if ($productId > 0)
{
// Invalidate cached images for this product
$imageService = new ImageService();
$imageService->invalidateCache($productId);
// Clear API cache
CacheService::forget('products_list');
CacheService::forget('product_' . $productId);
}
}
/**
* Handle product deletion.
*
* @param array $payload Event payload.
*
* @return void
*
* @since 1.0.0
*/
private function handleProductDelete(array $payload): void
{
$productId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
if ($productId > 0)
{
$imageService = new ImageService();
$imageService->invalidateCache($productId);
CacheService::flush();
}
}
/**
* Handle order status changes — update local mapping.
*
* @param array $payload Event payload.
*
* @return void
*
* @since 1.0.0
*/
private function handleOrderStatusChange(array $payload): void
{
$orderId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
if ($orderId <= 0)
{
return;
}
$statusMap = [
-1 => 'cancelled',
0 => 'draft',
1 => 'validated',
2 => 'shipped',
3 => 'delivered',
];
$statusCode = (int) ($payload['object_status'] ?? $payload['status'] ?? 0);
$newStatus = $statusMap[$statusCode] ?? 'unknown';
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true);
$query->update($db->quoteName('#__mokodolijoomshop_orders'))
->set($db->quoteName('status') . ' = ' . $db->quote($newStatus))
->where($db->quoteName('dolibarr_order_id') . ' = ' . $orderId);
$db->setQuery($query);
$db->execute();
}
/**
* Handle payment received — update order status to paid.
*
* @param array $payload Event payload.
*
* @return void
*
* @since 1.0.0
*/
private function handlePaymentReceived(array $payload): void
{
$invoiceId = (int) ($payload['object_id'] ?? 0);
if ($invoiceId <= 0)
{
return;
}
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true);
$query->update($db->quoteName('#__mokodolijoomshop_orders'))
->set($db->quoteName('status') . ' = ' . $db->quote('paid'))
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
$db->setQuery($query);
$db->execute();
}
/**
* Log a webhook event to the database.
*
* @param string $eventType Event type.
* @param array $payload Payload data.
* @param string $status Processing status.
* @param string $message Optional message.
*
* @return void
*
* @since 1.0.0
*/
private function logEvent(string $eventType, array $payload, string $status, string $message = ''): void
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true);
$query->insert($db->quoteName('#__mokodolijoomshop_webhook_log'))
->columns(['event_type', 'payload', 'status', 'message'])
->values(implode(',', [
$db->quote($eventType),
$db->quote(json_encode($payload)),
$db->quote($status),
$db->quote(mb_substr($message, 0, 500)),
]));
$db->setQuery($query);
$db->execute();
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
/**
* Cart item table class.
*
* @since 1.0.0
*/
class CartTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database connector.
*
* @since 1.0.0
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokodolijoomshop_cart', 'id', $db);
}
/**
* Validation before store.
*
* @return bool
*
* @since 1.0.0
*/
public function check(): bool
{
if (empty($this->session_id) && empty($this->user_id))
{
$this->setError('Cart item must have a session_id or user_id.');
return false;
}
if (empty($this->dolibarr_product_id))
{
$this->setError('Cart item must have a product ID.');
return false;
}
if ($this->quantity < 1)
{
$this->quantity = 1;
}
return true;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
/**
* Customer mapping table class.
*
* @since 1.0.0
*/
class CustomerTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database connector.
*
* @since 1.0.0
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokodolijoomshop_customers', 'id', $db);
}
/**
* Validation before store.
*
* @return bool
*
* @since 1.0.0
*/
public function check(): bool
{
if (empty($this->user_id))
{
$this->setError('Customer mapping must have a Joomla user_id.');
return false;
}
if (empty($this->dolibarr_thirdparty_id))
{
$this->setError('Customer mapping must have a Dolibarr thirdparty_id.');
return false;
}
return true;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
/**
* Order mapping table class.
*
* @since 1.0.0
*/
class OrderTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database connector.
*
* @since 1.0.0
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokodolijoomshop_orders', 'id', $db);
}
/**
* Validation before store.
*
* @return bool
*
* @since 1.0.0
*/
public function check(): bool
{
if (empty($this->dolibarr_order_id))
{
$this->setError('Order mapping must have a Dolibarr order ID.');
return false;
}
return true;
}
}
+59 -1
View File
@@ -10,9 +10,11 @@ namespace Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
use Moko\Component\MokoDoliJoomShop\Administrator\Model\DashboardModel;
/**
* Dashboard view for the admin.
@@ -27,6 +29,48 @@ class HtmlView extends BaseHtmlView
*/
protected bool $connectionOk = false;
/**
* @var array Detailed connection status from DolibarrClient.
* @since 1.0.0
*/
protected array $connectionStatus = [];
/**
* @var int Product count.
* @since 1.0.0
*/
protected int $productCount = 0;
/**
* @var int Order count.
* @since 1.0.0
*/
protected int $orderCount = 0;
/**
* @var int Customer count.
* @since 1.0.0
*/
protected int $customerCount = 0;
/**
* @var array Recent orders.
* @since 1.0.0
*/
protected array $recentOrders = [];
/**
* @var array Revenue metrics.
* @since 1.0.0
*/
protected array $revenue = [];
/**
* @var string Currency.
* @since 1.0.0
*/
protected string $currency = 'USD';
/**
* Display the dashboard.
*
@@ -39,7 +83,21 @@ class HtmlView extends BaseHtmlView
public function display($tpl = null): void
{
$client = new DolibarrClient();
$this->connectionOk = $client->testConnection();
$this->connectionStatus = $client->testConnectionDetailed();
$this->connectionOk = $this->connectionStatus['ok'];
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$this->currency = $params->get('currency', 'USD');
if ($this->connectionOk)
{
$dashModel = new DashboardModel();
$this->productCount = $dashModel->getProductCount();
$this->orderCount = $dashModel->getOrderCount();
$this->customerCount = $dashModel->getCustomerCount();
$this->recentOrders = $dashModel->getRecentOrders(5);
$this->revenue = $dashModel->getRevenue();
}
ToolbarHelper::title('DoliJoom Shop: Dashboard');
ToolbarHelper::preferences('com_mokodolijoomshop');
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Administrator\View\Orders;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Admin orders list view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array Order items.
* @since 1.0.0
*/
protected array $items = [];
/**
* @var string Currency.
* @since 1.0.0
*/
protected string $currency = 'USD';
/**
* Display the orders list.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$model = $this->getModel();
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$this->items = $model->getItems();
$this->currency = $params->get('currency', 'USD');
ToolbarHelper::title('DoliJoom Shop: Orders');
parent::display($tpl);
}
}
+137 -9
View File
@@ -9,49 +9,177 @@
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard\HtmlView $this */
$status = $this->connectionStatus;
$currency = htmlspecialchars($this->currency);
?>
<div class="com-mokodolijoomshop-dashboard">
<div class="row">
<!-- Connection Status -->
<div class="row mb-4">
<div class="col-lg-6">
<div class="card mb-3">
<div class="card">
<div class="card-header">
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_FIELDSET_DOLIBARR'); ?></h3>
</div>
<div class="card-body">
<?php if ($this->connectionOk) : ?>
<div class="alert alert-success">
<div class="alert alert-success mb-2">
<span class="icon-check" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_OK'); ?>
</div>
<?php if (!empty($status['version'])) : ?>
<p class="mb-1">
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_DOLIBARR_VERSION'); ?>:</strong>
<?php echo htmlspecialchars($status['version']); ?>
</p>
<?php endif; ?>
<p class="mb-0">
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSIONS'); ?>:</strong>
<span class="icon-<?php echo $status['permissions']['read'] ? 'check text-success' : 'times text-danger'; ?>" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSION_READ'); ?>
&nbsp;
<span class="icon-<?php echo $status['permissions']['write'] ? 'check text-success' : 'times text-danger'; ?>" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSION_WRITE'); ?>
</p>
<?php else : ?>
<div class="alert alert-danger">
<span class="icon-warning" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED'); ?>
<?php echo htmlspecialchars($status['error'] ?: Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED')); ?>
</div>
<?php if (!empty($status['hint'])) : ?>
<div class="alert alert-info mb-0">
<span class="icon-info-circle" aria-hidden="true"></span>
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TROUBLESHOOTING'); ?>:</strong>
<?php echo htmlspecialchars($status['hint']); ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-3">
<div class="card">
<div class="card-header">
<h3 class="card-title">Quick Actions</h3>
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUICK_ACTIONS'); ?></h3>
</div>
<div class="card-body">
<a href="index.php?option=com_mokodolijoomshop&view=products" class="btn btn-primary mb-2 d-block">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary mb-2 d-block">
<span class="icon-cube" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
</a>
<a href="index.php?option=com_mokodolijoomshop&view=orders" class="btn btn-outline-primary mb-2 d-block">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" class="btn btn-outline-primary mb-2 d-block">
<span class="icon-cart" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?>
</a>
<a href="index.php?option=com_mokodolijoomshop&view=customers" class="btn btn-outline-primary mb-2 d-block">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=customers'); ?>" class="btn btn-outline-primary mb-2 d-block">
<span class="icon-users" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMERS'); ?>
</a>
</div>
</div>
</div>
</div>
<?php if ($this->connectionOk) : ?>
<!-- Metrics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?></h5>
<p class="display-6 fw-bold mb-0"><?php echo $this->productCount; ?></p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?></h5>
<p class="display-6 fw-bold mb-0"><?php echo $this->orderCount; ?></p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMERS'); ?></h5>
<p class="display-6 fw-bold mb-0"><?php echo $this->customerCount; ?></p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_MONTH'); ?></h5>
<p class="display-6 fw-bold mb-0"><?php echo number_format($this->revenue['month'] ?? 0, 2); ?> <?php echo $currency; ?></p>
</div>
</div>
</div>
</div>
<!-- Revenue Breakdown & Recent Orders -->
<div class="row">
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE'); ?></h4>
</div>
<div class="card-body">
<table class="table table-sm">
<tr>
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_TODAY'); ?></td>
<td class="text-end fw-bold"><?php echo number_format($this->revenue['today'] ?? 0, 2); ?> <?php echo $currency; ?></td>
</tr>
<tr>
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_WEEK'); ?></td>
<td class="text-end fw-bold"><?php echo number_format($this->revenue['week'] ?? 0, 2); ?> <?php echo $currency; ?></td>
</tr>
<tr>
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_MONTH'); ?></td>
<td class="text-end fw-bold"><?php echo number_format($this->revenue['month'] ?? 0, 2); ?> <?php echo $currency; ?></td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_RECENT_ORDERS'); ?></h4>
</div>
<div class="card-body p-0">
<?php if (empty($this->recentOrders)) : ?>
<p class="p-3 text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></p>
<?php else : ?>
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->recentOrders as $order) : ?>
<tr>
<td><?php echo htmlspecialchars($order['order_ref']); ?></td>
<td class="text-end"><?php echo number_format((float) $order['total_ttc'], 2); ?> <?php echo $currency; ?></td>
<td><span class="badge bg-secondary"><?php echo htmlspecialchars($order['status']); ?></span></td>
<td><?php echo htmlspecialchars($order['created']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Orders\HtmlView $this */
$currency = htmlspecialchars($this->currency);
?>
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" method="get" id="adminForm" name="adminForm">
<input type="hidden" name="option" value="com_mokodolijoomshop" />
<input type="hidden" name="view" value="orders" />
<!-- Filters -->
<div class="row mb-3">
<div class="col-md-3">
<input type="text" name="filter_search" class="form-control" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_SEARCH'); ?>"
value="<?php echo htmlspecialchars(\Joomla\CMS\Factory::getApplication()->input->getString('filter_search', '')); ?>" />
</div>
<div class="col-md-2">
<select name="filter_status" class="form-select">
<option value=""><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="validated">Validated</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="col-md-2">
<input type="date" name="filter_date_from" class="form-control" placeholder="From" />
</div>
<div class="col-md-2">
<input type="date" name="filter_date_to" class="form-control" placeholder="To" />
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary">
<span class="icon-search" aria-hidden="true"></span>
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
</button>
</div>
</div>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
<?php else : ?>
<table class="table table-striped" id="orderList">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_HT'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $order) : ?>
<?php
$statusClass = match ($order['status'] ?? '') {
'confirmed', 'validated' => 'bg-success',
'shipped' => 'bg-info',
'delivered' => 'bg-primary',
'cancelled' => 'bg-danger',
default => 'bg-secondary',
};
?>
<tr>
<td><?php echo htmlspecialchars($order['created']); ?></td>
<td><?php echo htmlspecialchars($order['customer_name'] ?? ''); ?></td>
<td><?php echo htmlspecialchars($order['order_ref']); ?></td>
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
<td class="text-end"><?php echo number_format((float) $order['total_ht'], 2); ?> <?php echo $currency; ?></td>
<td class="text-end"><?php echo number_format((float) $order['total_ttc'], 2); ?> <?php echo $currency; ?></td>
<td><span class="badge <?php echo $statusClass; ?>"><?php echo htmlspecialchars($order['status']); ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</form>
+73
View File
@@ -37,8 +37,14 @@
</sql>
</uninstall>
<media destination="com_mokodolijoomshop" folder="../media/com_mokodolijoomshop">
<folder>css</folder>
<folder>images</folder>
</media>
<files folder="site">
<folder>language</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
@@ -127,10 +133,77 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="tax_display"
type="list"
label="COM_MOKODOLIJOOMSHOP_FIELD_TAX_DISPLAY"
default="ttc"
>
<option value="ttc">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_TTC</option>
<option value="ht">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_HT</option>
<option value="both">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_BOTH</option>
</field>
<field
name="low_stock_threshold"
type="number"
label="COM_MOKODOLIJOOMSHOP_FIELD_LOW_STOCK_THRESHOLD"
default="5"
min="0"
max="999"
/>
<field
name="allow_backorder"
type="radio"
label="COM_MOKODOLIJOOMSHOP_FIELD_ALLOW_BACKORDER"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="performance" label="COM_MOKODOLIJOOMSHOP_FIELDSET_PERFORMANCE">
<field
name="cache_enabled"
type="radio"
label="COM_MOKODOLIJOOMSHOP_FIELD_CACHE_ENABLED"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="cache_ttl"
type="number"
label="COM_MOKODOLIJOOMSHOP_FIELD_CACHE_TTL"
default="900"
min="60"
max="86400"
/>
</fieldset>
<fieldset name="webhooks" label="COM_MOKODOLIJOOMSHOP_FIELDSET_WEBHOOKS">
<field
name="webhook_secret"
type="password"
label="COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET"
description="COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET_DESC"
/>
</fieldset>
</fields>
</config>
<access section="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="mokodolijoomshop.products.manage" title="COM_MOKODOLIJOOMSHOP_ACL_PRODUCTS_MANAGE" />
<action name="mokodolijoomshop.orders.view" title="COM_MOKODOLIJOOMSHOP_ACL_ORDERS_VIEW" />
<action name="mokodolijoomshop.customers.manage" title="COM_MOKODOLIJOOMSHOP_ACL_CUSTOMERS_MANAGE" />
<action name="mokodolijoomshop.settings.manage" title="COM_MOKODOLIJOOMSHOP_ACL_SETTINGS_MANAGE" />
</access>
<updateservers>
<server type="extension" name="MokoDoliJoomShop Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoDoliJoomShop/raw/branch/main/updates.xml</server>
</updateservers>
@@ -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"
+2 -1
View File
@@ -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.
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Language\Text;
/**
* Cart controller — handles add, update, and remove actions.
*
* @since 1.0.0
*/
class CartController extends BaseController
{
/**
* Add a product to the cart.
*
* @return void
*
* @since 1.0.0
*/
public function add(): void
{
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
$productId = $this->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));
}
}
@@ -0,0 +1,102 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/**
* Checkout controller — processes the checkout form submission.
*
* @since 1.0.0
*/
class CheckoutController extends BaseController
{
/**
* Process the checkout form.
*
* @return void
*
* @since 1.0.0
*/
public function process(): void
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CheckoutModel $checkoutModel */
$checkoutModel = $this->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));
}
}
@@ -0,0 +1,121 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Invoice controller — handles PDF download for frontend users.
*
* @since 1.0.0
*/
class InvoiceController extends BaseController
{
/**
* Download an invoice PDF.
*
* Streams the PDF directly from Dolibarr to the browser.
* Access is restricted to the order owner.
*
* @return void
*
* @since 1.0.0
*/
public function download(): void
{
$userId = (int) Factory::getApplication()->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();
}
}
@@ -0,0 +1,111 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\MVC\Controller\BaseController;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Search controller — provides AJAX product search and filtering.
*
* @since 1.0.0
*/
class SearchController extends BaseController
{
/**
* AJAX search endpoint.
*
* Accepts: q (text), category_id, price_min, price_max, sort, page.
* Returns JSON array of products.
*
* @return void
*
* @since 1.0.0
*/
public function search(): void
{
$client = new DolibarrClient();
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$perPage = (int) $params->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();
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
defined('_JEXEC') or die;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Coupon/discount code helper — validates codes against Dolibarr discount rules.
*
* @since 1.0.0
*/
class CouponHelper
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$this->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);
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Language\Text;
/**
* Stock display helper — determines stock status and badge rendering.
*
* @since 1.0.0
*/
class StockHelper
{
public const STATUS_IN_STOCK = 'in_stock';
public const STATUS_LOW_STOCK = 'low_stock';
public const STATUS_OUT = 'out_of_stock';
/**
* Determine stock status for a given quantity.
*
* @param float $stockQty Stock quantity.
*
* @return string One of the STATUS_ constants.
*
* @since 1.0.0
*/
public static function getStatus(float $stockQty): string
{
if ($stockQty <= 0)
{
return self::STATUS_OUT;
}
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$threshold = (int) $params->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 '<span class="badge ' . $class . '">' . $text . '</span>';
}
/**
* 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);
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
/**
* Tax calculation and display helper.
*
* @since 1.0.0
*/
class TaxHelper
{
/**
* Get the configured tax display mode.
*
* @return string 'ttc' (incl. tax), 'ht' (excl. tax), or 'both'.
*
* @since 1.0.0
*/
public static function getDisplayMode(): string
{
$params = ComponentHelper::getParams('com_mokodolijoomshop');
return $params->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
. ' <small class="text-muted">(' . number_format($priceHT, 2) . ' HT)</small>';
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,
];
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
defined('_JEXEC') or die;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Product variant helper — handles Dolibarr product variants/combinations.
*
* @since 1.0.0
*/
class VariantHelper
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$this->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;
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
/**
* Shipping address model — manages user address book.
*
* @since 1.0.0
*/
class AddressModel extends BaseDatabaseModel
{
/**
* Get all addresses for the current user.
*
* @return array
*
* @since 1.0.0
*/
public function getAddresses(): array
{
$userId = (int) Factory::getApplication()->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;
}
}
+385
View File
@@ -0,0 +1,385 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Shopping cart model — session-based with DB persistence.
*
* @since 1.0.0
*/
class CartModel extends BaseDatabaseModel
{
/**
* Get the current session identifier.
*
* @return string
*
* @since 1.0.0
*/
public function getSessionId(): string
{
return Factory::getApplication()->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()));
}
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Category model — fetches product categories and their products from Dolibarr.
*
* @since 1.0.0
*/
class CategoryModel extends BaseDatabaseModel
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @param array $config Configuration array.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
parent::__construct($config);
$this->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;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Service\CustomerSyncService;
use Moko\Component\MokoDoliJoomShop\Administrator\Service\OrderService;
/**
* Checkout model — handles the full checkout process.
*
* @since 1.0.0
*/
class CheckoutModel extends BaseDatabaseModel
{
/**
* Determine if the current user can checkout (based on checkout_mode config).
*
* @return bool
*
* @since 1.0.0
*/
public function canCheckout(): bool
{
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$mode = $params->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,
];
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Order history model — retrieves user's orders from local mapping and Dolibarr.
*
* @since 1.0.0
*/
class OrdersModel extends BaseDatabaseModel
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @param array $config Configuration array.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
parent::__construct($config);
$this->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';
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Single product model — fetches product detail from Dolibarr API.
*
* @since 1.0.0
*/
class ProductModel extends BaseDatabaseModel
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @param array $config Configuration array.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
parent::__construct($config);
$this->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;
}));
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Products list model — fetches products from Dolibarr API.
*
* @since 1.0.0
*/
class ProductsModel extends BaseDatabaseModel
{
/**
* @var DolibarrClient
* @since 1.0.0
*/
private DolibarrClient $client;
/**
* Constructor.
*
* @param array $config Configuration array.
*
* @since 1.0.0
*/
public function __construct($config = [])
{
parent::__construct($config);
$this->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);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
/**
* Wishlist model — save for later functionality.
*
* @since 1.0.0
*/
class WishlistModel extends BaseDatabaseModel
{
/**
* Get wishlist items for the current user.
*
* @return array
*
* @since 1.0.0
*/
public function getItems(): array
{
$db = $this->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();
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Component\Router\RouterBase;
use Joomla\CMS\Menu\AbstractMenu;
/**
* SEF URL router for com_mokodolijoomshop.
*
* URL patterns:
* /shop → products view
* /shop/cart → cart view
* /shop/checkout → checkout view
* /shop/my-orders → orders view
* /shop/category/{id} → category view
* /shop/product/{id} → product view
*
* @since 1.0.0
*/
class Router extends RouterBase
{
/**
* Build SEF URL segments from query parameters.
*
* @param array &$query Query parameters.
*
* @return array URL segments.
*
* @since 1.0.0
*/
public function build(&$query): array
{
$segments = [];
$view = $query['view'] ?? 'products';
unset($query['view']);
switch ($view)
{
case 'products':
// No extra segment — the menu item handles it
break;
case 'product':
$segments[] = 'product';
if (isset($query['id']))
{
$segments[] = $query['id'];
unset($query['id']);
}
break;
case 'category':
$segments[] = 'category';
if (isset($query['id']))
{
$segments[] = $query['id'];
unset($query['id']);
}
break;
case 'cart':
$segments[] = 'cart';
break;
case 'checkout':
$segments[] = 'checkout';
break;
case 'orders':
$segments[] = 'my-orders';
if (isset($query['order_id']))
{
$segments[] = $query['order_id'];
unset($query['order_id']);
}
break;
}
// Handle task-based URLs (cart.add, cart.remove, etc.)
if (isset($query['task']))
{
// Keep task in query for controller routing
}
return $segments;
}
/**
* Parse SEF URL segments into query parameters.
*
* @param array &$segments URL segments.
*
* @return array Query parameters.
*
* @since 1.0.0
*/
public function parse(&$segments): array
{
$vars = [];
$count = \count($segments);
if ($count === 0)
{
$vars['view'] = 'products';
return $vars;
}
$first = $segments[0];
switch ($first)
{
case 'product':
$vars['view'] = 'product';
if ($count > 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;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\View\Cart;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Shopping cart view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array Cart items.
* @since 1.0.0
*/
protected array $items = [];
/**
* @var array Cart totals (subtotal, tax, total, count).
* @since 1.0.0
*/
protected array $totals = [];
/**
* @var string Currency code.
* @since 1.0.0
*/
protected string $currency = 'USD';
/**
* Display the cart.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$model = $this->getModel();
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$this->items = $model->getItems();
$this->totals = $model->getTotals();
$this->currency = $params->get('currency', 'USD');
parent::display($tpl);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\View\Category;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Category landing page view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array|null Current category data.
* @since 1.0.0
*/
protected ?array $category = null;
/**
* @var array Products in this category.
* @since 1.0.0
*/
protected array $items = [];
/**
* @var array Category tree for sidebar.
* @since 1.0.0
*/
protected array $categoryTree = [];
/**
* @var array Breadcrumbs path.
* @since 1.0.0
*/
protected array $breadcrumbs = [];
/**
* @var string Currency code.
* @since 1.0.0
*/
protected string $currency = 'USD';
/**
* @var int Current page.
* @since 1.0.0
*/
protected int $page = 0;
/**
* @var int Per page count.
* @since 1.0.0
*/
protected int $perPage = 12;
/**
* Display the category page.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$model = $this->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);
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\View\Checkout;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Checkout view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array Cart items.
* @since 1.0.0
*/
protected array $cartItems = [];
/**
* @var array Cart totals.
* @since 1.0.0
*/
protected array $totals = [];
/**
* @var string Currency code.
* @since 1.0.0
*/
protected string $currency = 'USD';
/**
* @var string Checkout mode.
* @since 1.0.0
*/
protected string $checkoutMode = 'both';
/**
* @var \Joomla\CMS\User\User|null Current user.
* @since 1.0.0
*/
protected $user = null;
/**
* @var array|null Order result for confirmation page.
* @since 1.0.0
*/
protected ?array $orderResult = null;
/**
* Display the checkout view.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$app = Factory::getApplication();
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$layout = $app->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);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\View\Orders;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Order history view (My Orders).
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array User's orders.
* @since 1.0.0
*/
protected array $orders = [];
/**
* @var array|null Single order detail.
* @since 1.0.0
*/
protected ?array $orderDetail = null;
/**
* @var string Currency.
* @since 1.0.0
*/
protected string $currency = 'USD';
/**
* @var bool Whether user is logged in.
* @since 1.0.0
*/
protected bool $isGuest = true;
/**
* Display the orders view.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$app = Factory::getApplication();
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$model = $this->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);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\View\Product;
defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
/**
* Single product detail view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array|null Product data.
* @since 1.0.0
*/
protected ?array $item = null;
/**
* @var float Stock quantity.
* @since 1.0.0
*/
protected float $stock = 0.0;
/**
* @var array Product images.
* @since 1.0.0
*/
protected array $images = [];
/**
* @var array Related products.
* @since 1.0.0
*/
protected array $related = [];
/**
* @var string Currency code.
* @since 1.0.0
*/
protected string $currency = 'USD';
/**
* Display the product detail page.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$model = $this->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);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoDoliJoomShop\Site\View\Products;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Component\ComponentHelper;
/**
* Product catalog listing view.
*
* @since 1.0.0
*/
class HtmlView extends BaseHtmlView
{
/**
* @var array Product items from Dolibarr.
* @since 1.0.0
*/
protected array $items = [];
/**
* @var array Product categories.
* @since 1.0.0
*/
protected array $categories = [];
/**
* @var int Current page number.
* @since 1.0.0
*/
protected int $page = 0;
/**
* @var int Active category filter.
* @since 1.0.0
*/
protected int $categoryId = 0;
/**
* @var string Currency code.
* @since 1.0.0
*/
protected string $currency = 'USD';
/**
* @var int Products per page.
* @since 1.0.0
*/
protected int $perPage = 12;
/**
* Display the products catalog.
*
* @param string $tpl Template name.
*
* @return void
*
* @since 1.0.0
*/
public function display($tpl = null): void
{
$model = $this->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);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Cart\HtmlView $this */
$currency = htmlspecialchars($this->currency);
?>
<div class="com-mokodolijoomshop-cart">
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART'); ?></h2>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'); ?>
</div>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
</a>
<?php else : ?>
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_REF'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE'); ?></th>
<th class="text-center"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $item) : ?>
<?php $lineTotal = (float) $item['unit_price'] * (int) $item['quantity']; ?>
<tr>
<td>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . (int) $item['dolibarr_product_id']); ?>">
<?php echo htmlspecialchars($item['product_label']); ?>
</a>
</td>
<td class="text-muted"><?php echo htmlspecialchars($item['product_ref']); ?></td>
<td class="text-end"><?php echo number_format((float) $item['unit_price'], 2); ?> <?php echo $currency; ?></td>
<td class="text-center">
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.update'); ?>" method="post" class="d-inline">
<input type="hidden" name="cart_item_id" value="<?php echo (int) $item['id']; ?>" />
<input type="number" name="quantity" value="<?php echo (int) $item['quantity']; ?>" min="1" max="999" class="form-control form-control-sm d-inline-block" style="width: 70px;" onchange="this.form.submit();" />
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</td>
<td class="text-end fw-bold"><?php echo number_format($lineTotal, 2); ?> <?php echo $currency; ?></td>
<td>
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.remove'); ?>" method="post" class="d-inline">
<input type="hidden" name="cart_item_id" value="<?php echo (int) $item['id']; ?>" />
<button type="submit" class="btn btn-sm btn-outline-danger" title="<?php echo Text::_('JACTION_DELETE'); ?>">
<span class="icon-trash" aria-hidden="true"></span>
</button>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="row justify-content-end">
<div class="col-md-4">
<table class="table table-sm">
<tr>
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></td>
<td class="text-end"><?php echo number_format($this->totals['subtotal'], 2); ?> <?php echo $currency; ?></td>
</tr>
<?php if ($this->totals['tax'] > 0) : ?>
<tr>
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TAX'); ?></td>
<td class="text-end"><?php echo number_format($this->totals['tax'], 2); ?> <?php echo $currency; ?></td>
</tr>
<?php endif; ?>
<tr class="fw-bold fs-5">
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></td>
<td class="text-end"><?php echo number_format($this->totals['total'], 2); ?> <?php echo $currency; ?></td>
</tr>
</table>
<div class="d-grid gap-2">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=checkout'); ?>" class="btn btn-primary btn-lg">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-outline-secondary">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
</a>
</div>
</div>
</div>
<?php endif; ?>
</div>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKODOLIJOOMSHOP_CART">
<message>COM_MOKODOLIJOOMSHOP_CART_DESC</message>
</layout>
</metadata>
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Category\HtmlView $this */
if ($this->category === null) :
?>
<div class="alert alert-warning"><?php echo Text::_('JGLOBAL_RESOURCE_NOT_FOUND'); ?></div>
<?php return;
endif;
$currency = htmlspecialchars($this->currency);
$catLabel = htmlspecialchars($this->category['label'] ?? '');
$catDesc = $this->category['description'] ?? '';
$categoryId = (int) $this->category['id'];
?>
<div class="com-mokodolijoomshop-category">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
</a>
</li>
<?php foreach ($this->breadcrumbs as $i => $crumb) : ?>
<?php if ($i === \count($this->breadcrumbs) - 1) : ?>
<li class="breadcrumb-item active" aria-current="page"><?php echo htmlspecialchars($crumb['label']); ?></li>
<?php else : ?>
<li class="breadcrumb-item">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=category&id=' . (int) $crumb['id']); ?>">
<?php echo htmlspecialchars($crumb['label']); ?>
</a>
</li>
<?php endif; ?>
<?php endforeach; ?>
</ol>
</nav>
<div class="row">
<!-- Category sidebar -->
<div class="col-md-3">
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CATEGORIES'); ?></h5>
</div>
<div class="card-body p-0">
<?php echo mokoshop_render_category_tree($this->categoryTree, $categoryId); ?>
</div>
</div>
</div>
<!-- Products grid -->
<div class="col-md-9">
<h2><?php echo $catLabel; ?></h2>
<?php if ($catDesc) : ?>
<p class="text-muted"><?php echo $catDesc; ?></p>
<?php endif; ?>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_PRODUCTS'); ?></div>
<?php else : ?>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
<?php foreach ($this->items as $product) : ?>
<?php
$productId = (int) $product['id'];
$label = htmlspecialchars($product['label'] ?? $product['ref'] ?? '');
$price = (float) ($product['price_ttc'] ?? $product['price'] ?? 0);
$detailLink = Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . $productId);
$stockReel = (float) ($product['stock_reel'] ?? 0);
$inStock = $stockReel > 0;
?>
<div class="col">
<div class="card h-100 product-card">
<div class="card-body">
<h5 class="card-title">
<a href="<?php echo $detailLink; ?>"><?php echo $label; ?></a>
</h5>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="fw-bold"><?php echo number_format($price, 2); ?> <?php echo $currency; ?></span>
<?php if ($inStock) : ?>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId); ?>" class="btn btn-sm btn-primary">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
</a>
<?php else : ?>
<span class="badge bg-secondary"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php $baseUrl = 'index.php?option=com_mokodolijoomshop&view=category&id=' . $categoryId; ?>
<nav class="mt-4" aria-label="<?php echo Text::_('JLIB_HTML_PAGINATION'); ?>">
<ul class="pagination justify-content-center">
<?php if ($this->page > 0) : ?>
<li class="page-item">
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page - 1)); ?>">&laquo; <?php echo Text::_('JPREV'); ?></a>
</li>
<?php endif; ?>
<?php if (\count($this->items) >= $this->perPage) : ?>
<li class="page-item">
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page + 1)); ?>"><?php echo Text::_('JNEXT'); ?> &raquo;</a>
</li>
<?php endif; ?>
</ul>
</nav>
<?php endif; ?>
</div>
</div>
</div>
<?php
// Render the category tree as nested lists
if (!function_exists('mokoshop_render_category_tree'))
{
function mokoshop_render_category_tree(array $tree, int $activeId): string
{
if (empty($tree))
{
return '';
}
$html = '<ul class="list-group list-group-flush">';
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 .= '<li class="list-group-item' . $active . '">';
$html .= '<a href="' . $link . '">' . $label . '</a>';
if (!empty($cat['children']))
{
$html .= mokoshop_render_category_tree($cat['children'], $activeId);
}
$html .= '</li>';
}
$html .= '</ul>';
return $html;
}
}
?>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKODOLIJOOMSHOP_CATEGORY">
<message>COM_MOKODOLIJOOMSHOP_CATEGORY_DESC</message>
</layout>
<fields name="params">
<fieldset name="request" label="COM_MOKODOLIJOOMSHOP_CATEGORY_OPTIONS">
<field
name="id"
type="number"
label="COM_MOKODOLIJOOMSHOP_CATEGORY_ID"
description="COM_MOKODOLIJOOMSHOP_CATEGORY_ID_DESC"
required="true"
/>
</fieldset>
</fields>
</metadata>
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Checkout\HtmlView $this */
$order = $this->orderResult;
?>
<div class="com-mokodolijoomshop-checkout-confirmation">
<?php if ($order === null) : ?>
<div class="alert alert-warning">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDER_DATA'); ?>
</div>
<?php else : ?>
<div class="text-center py-5">
<span class="icon-check-circle text-success" style="font-size: 4rem;" aria-hidden="true"></span>
<h2 class="mt-3"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_PLACED'); ?></h2>
<div class="card mx-auto mt-4" style="max-width: 400px;">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></dt>
<dd class="col-sm-7 fw-bold"><?php echo htmlspecialchars($order['order_ref'] ?? ''); ?></dd>
<?php if (!empty($order['invoice_ref'])) : ?>
<dt class="col-sm-5"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></dt>
<dd class="col-sm-7"><?php echo htmlspecialchars($order['invoice_ref']); ?></dd>
<?php endif; ?>
</dl>
</div>
</div>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary mt-4">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
</a>
</div>
<?php endif; ?>
</div>
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Checkout\HtmlView $this */
$currency = htmlspecialchars($this->currency);
$isGuest = empty($this->user->id);
$userName = $isGuest ? '' : htmlspecialchars($this->user->name);
$userEmail = $isGuest ? '' : htmlspecialchars($this->user->email);
?>
<div class="com-mokodolijoomshop-checkout">
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT'); ?></h2>
<?php if (empty($this->cartItems)) : ?>
<div class="alert alert-warning"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'); ?></div>
<?php return; ?>
<?php endif; ?>
<?php if ($this->checkoutMode === 'registered' && $isGuest) : ?>
<div class="alert alert-info">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED'); ?>
<a href="<?php echo Route::_('index.php?option=com_users&view=login'); ?>" class="btn btn-primary ms-2">
<?php echo Text::_('JLOGIN'); ?>
</a>
</div>
<?php return; ?>
<?php endif; ?>
<div class="row">
<div class="col-md-7">
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=checkout.process'); ?>" method="post" id="checkoutForm">
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_DETAILS'); ?></h4>
</div>
<div class="card-body">
<div class="mb-3">
<label for="billing_name" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_NAME'); ?> *</label>
<input type="text" id="billing_name" name="billing_name" class="form-control" required value="<?php echo $userName; ?>" />
</div>
<div class="mb-3">
<label for="billing_email" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_EMAIL'); ?> *</label>
<input type="email" id="billing_email" name="billing_email" class="form-control" required value="<?php echo $userEmail; ?>" />
</div>
<div class="mb-3">
<label for="billing_address" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_ADDRESS'); ?></label>
<textarea id="billing_address" name="billing_address" class="form-control" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="billing_town" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_TOWN'); ?></label>
<input type="text" id="billing_town" name="billing_town" class="form-control" />
</div>
<div class="col-md-6 mb-3">
<label for="billing_zip" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_ZIP'); ?></label>
<input type="text" id="billing_zip" name="billing_zip" class="form-control" />
</div>
</div>
<div class="mb-3">
<label for="billing_phone" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_PHONE'); ?></label>
<input type="tel" id="billing_phone" name="billing_phone" class="form-control" />
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_NOTES'); ?></h4>
</div>
<div class="card-body">
<textarea name="order_notes" class="form-control" rows="3" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_NOTES_PLACEHOLDER'); ?>"></textarea>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
<span class="icon-cart" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PLACE_ORDER'); ?>
</button>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
<div class="col-md-5">
<div class="card">
<div class="card-header">
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_SUMMARY'); ?></h4>
</div>
<div class="card-body">
<?php foreach ($this->cartItems as $item) : ?>
<div class="d-flex justify-content-between mb-2">
<span>
<?php echo htmlspecialchars($item['product_label']); ?>
<small class="text-muted">&times; <?php echo (int) $item['quantity']; ?></small>
</span>
<span><?php echo number_format((float) $item['unit_price'] * (int) $item['quantity'], 2); ?> <?php echo $currency; ?></span>
</div>
<?php endforeach; ?>
<hr>
<div class="d-flex justify-content-between">
<span><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></span>
<span><?php echo number_format($this->totals['subtotal'], 2); ?> <?php echo $currency; ?></span>
</div>
<?php if ($this->totals['tax'] > 0) : ?>
<div class="d-flex justify-content-between">
<span><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TAX'); ?></span>
<span><?php echo number_format($this->totals['tax'], 2); ?> <?php echo $currency; ?></span>
</div>
<?php endif; ?>
<div class="d-flex justify-content-between fw-bold fs-5 mt-2">
<span><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></span>
<span><?php echo number_format($this->totals['total'], 2); ?> <?php echo $currency; ?></span>
</div>
</div>
</div>
</div>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKODOLIJOOMSHOP_CHECKOUT">
<message>COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC</message>
</layout>
</metadata>
+126
View File
@@ -0,0 +1,126 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Orders\HtmlView $this */
$currency = htmlspecialchars($this->currency);
?>
<div class="com-mokodolijoomshop-orders">
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_MY_ORDERS'); ?></h2>
<?php if ($this->isGuest) : ?>
<div class="alert alert-info">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS_LOGIN_REQUIRED'); ?>
<a href="<?php echo Route::_('index.php?option=com_users&view=login'); ?>" class="btn btn-primary ms-2">
<?php echo Text::_('JLOGIN'); ?>
</a>
</div>
<?php return; ?>
<?php endif; ?>
<?php if ($this->orderDetail !== null) : ?>
<?php // Order detail view ?>
<?php
$order = $this->orderDetail;
$ref = htmlspecialchars($order['ref'] ?? '');
?>
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_MY_ORDERS'); ?>
</a>
</li>
<li class="breadcrumb-item active"><?php echo $ref; ?></li>
</ol>
</nav>
<div class="card mb-3">
<div class="card-header d-flex justify-content-between">
<h4 class="mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?>: <?php echo $ref; ?></h4>
<span class="badge bg-info"><?php echo htmlspecialchars($order['statut_label'] ?? $order['status'] ?? ''); ?></span>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE'); ?></th>
<th class="text-center"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($order['lines'] ?? [] as $line) : ?>
<tr>
<td><?php echo htmlspecialchars($line['desc'] ?? $line['product_label'] ?? ''); ?></td>
<td class="text-end"><?php echo number_format((float) ($line['subprice'] ?? 0), 2); ?> <?php echo $currency; ?></td>
<td class="text-center"><?php echo (int) ($line['qty'] ?? 0); ?></td>
<td class="text-end"><?php echo number_format((float) ($line['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="fw-bold">
<td colspan="3" class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></td>
<td class="text-end"><?php echo number_format((float) ($order['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php elseif (empty($this->orders)) : ?>
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
</a>
<?php else : ?>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->orders as $order) : ?>
<tr>
<td><?php echo htmlspecialchars($order['created'] ?? ''); ?></td>
<td>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders&order_id=' . (int) $order['dolibarr_order_id']); ?>">
<?php echo htmlspecialchars($order['order_ref']); ?>
</a>
</td>
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
<td class="text-end"><?php echo number_format((float) ($order['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
<td>
<span class="badge bg-secondary"><?php echo htmlspecialchars($order['status'] ?? ''); ?></span>
</td>
<td>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders&order_id=' . (int) $order['dolibarr_order_id']); ?>" class="btn btn-sm btn-outline-primary">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_VIEW_DETAIL'); ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKODOLIJOOMSHOP_MY_ORDERS">
<message>COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC</message>
</layout>
</metadata>
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Product\HtmlView $this */
if ($this->item === null) :
?>
<div class="alert alert-warning"><?php echo Text::_('JGLOBAL_RESOURCE_NOT_FOUND'); ?></div>
<?php return;
endif;
$product = $this->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);
?>
<?php // Schema.org Product JSON-LD ?>
<script type="application/ld+json">
<?php echo json_encode([
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $label,
'description' => strip_tags($description),
'sku' => $ref,
'offers' => [
'@type' => 'Offer',
'price' => number_format($priceTTC, 2, '.', ''),
'priceCurrency' => $this->currency,
'availability' => $inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
],
], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); ?>
</script>
<div class="com-mokodolijoomshop-product">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
</a>
</li>
<li class="breadcrumb-item active" aria-current="page"><?php echo $label; ?></li>
</ol>
</nav>
<div class="row">
<div class="col-md-5">
<?php if (!empty($this->images)) : ?>
<div class="product-gallery mb-3">
<?php foreach ($this->images as $i => $image) : ?>
<img
src="<?php echo htmlspecialchars($image['url']); ?>"
alt="<?php echo $label; ?>"
class="img-fluid rounded <?php echo $i > 0 ? 'mt-2' : ''; ?>"
loading="<?php echo $i === 0 ? 'eager' : 'lazy'; ?>"
/>
<?php endforeach; ?>
</div>
<?php else : ?>
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="height: 300px;">
<span class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_IMAGE'); ?></span>
</div>
<?php endif; ?>
</div>
<div class="col-md-7">
<h1><?php echo $label; ?></h1>
<p class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_REF'); ?>: <?php echo $ref; ?></p>
<?php if ($barcode) : ?>
<p class="small text-muted">Barcode: <?php echo $barcode; ?></p>
<?php endif; ?>
<div class="mb-3">
<span class="fs-3 fw-bold">
<?php echo number_format($priceTTC, 2); ?> <?php echo htmlspecialchars($this->currency); ?>
</span>
<?php if ($priceHT !== $priceTTC) : ?>
<br>
<span class="text-muted small">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE_HT'); ?>: <?php echo number_format($priceHT, 2); ?> <?php echo htmlspecialchars($this->currency); ?>
</span>
<?php endif; ?>
</div>
<div class="mb-3">
<?php if ($inStock) : ?>
<span class="badge bg-success"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_IN_STOCK'); ?></span>
<span class="text-muted small ms-2">(<?php echo (int) $this->stock; ?> <?php echo Text::_('COM_MOKODOLIJOOMSHOP_AVAILABLE'); ?>)</span>
<?php else : ?>
<span class="badge bg-danger"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
<?php endif; ?>
</div>
<?php if ($inStock) : ?>
<form action="<?php echo $addCartLink; ?>" method="post" class="mb-4">
<div class="input-group" style="max-width: 250px;">
<label for="quantity" class="visually-hidden"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></label>
<input type="number" id="quantity" name="quantity" value="1" min="1" max="<?php echo (int) $this->stock; ?>" class="form-control" />
<button type="submit" class="btn btn-primary">
<span class="icon-cart" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
</button>
</div>
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
</form>
<?php endif; ?>
<?php if ($description) : ?>
<div class="product-description mt-4">
<h4><?php echo Text::_('COM_MOKODOLIJOOMSHOP_DESCRIPTION'); ?></h4>
<div><?php echo $description; ?></div>
</div>
<?php endif; ?>
</div>
</div>
<?php if (!empty($this->related)) : ?>
<div class="mt-5">
<h3><?php echo Text::_('COM_MOKODOLIJOOMSHOP_RELATED_PRODUCTS'); ?></h3>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-3">
<?php foreach (array_slice($this->related, 0, 4) as $rel) : ?>
<div class="col">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . (int) $rel['id']); ?>">
<?php echo htmlspecialchars($rel['label'] ?? $rel['ref'] ?? ''); ?>
</a>
</h6>
<span class="fw-bold">
<?php echo number_format((float) ($rel['price_ttc'] ?? $rel['price'] ?? 0), 2); ?>
<?php echo htmlspecialchars($this->currency); ?>
</span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL">
<message>COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC</message>
</layout>
<fields name="params">
<fieldset name="request" label="COM_MOKODOLIJOOMSHOP_PRODUCT_OPTIONS">
<field
name="id"
type="number"
label="COM_MOKODOLIJOOMSHOP_PRODUCT_ID"
description="COM_MOKODOLIJOOMSHOP_PRODUCT_ID_DESC"
required="true"
/>
</fieldset>
</fields>
</metadata>
+103
View File
@@ -0,0 +1,103 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Products\HtmlView $this */
?>
<div class="com-mokodolijoomshop-products">
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?></h2>
<?php if (!empty($this->categories)) : ?>
<nav class="shop-categories mb-4">
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>"
class="btn btn-sm <?php echo $this->categoryId === 0 ? 'btn-primary' : 'btn-outline-secondary'; ?> me-1 mb-1">
<?php echo Text::_('JALL'); ?>
</a>
<?php foreach ($this->categories as $cat) : ?>
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products&category_id=' . (int) $cat['id']); ?>"
class="btn btn-sm <?php echo $this->categoryId === (int) $cat['id'] ? 'btn-primary' : 'btn-outline-secondary'; ?> me-1 mb-1">
<?php echo htmlspecialchars($cat['label']); ?>
</a>
<?php endforeach; ?>
</nav>
<?php endif; ?>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_PRODUCTS'); ?>
</div>
<?php else : ?>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
<?php foreach ($this->items as $product) : ?>
<?php
$productId = (int) $product['id'];
$ref = htmlspecialchars($product['ref'] ?? '');
$label = htmlspecialchars($product['label'] ?? $ref);
$price = (float) ($product['price_ttc'] ?? $product['price'] ?? 0);
$description = htmlspecialchars(strip_tags($product['description'] ?? ''));
$detailLink = Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . $productId);
$addCartLink = Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId);
$stockReel = (float) ($product['stock_reel'] ?? 0);
$inStock = $stockReel > 0;
?>
<div class="col">
<div class="card h-100 product-card">
<div class="card-body">
<h5 class="card-title">
<a href="<?php echo $detailLink; ?>"><?php echo $label; ?></a>
</h5>
<p class="card-text text-muted small"><?php echo $ref; ?></p>
<?php if ($description) : ?>
<p class="card-text"><?php echo mb_strimwidth($description, 0, 120, '…'); ?></p>
<?php endif; ?>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="fw-bold fs-5">
<?php echo number_format($price, 2); ?>
<?php echo htmlspecialchars($this->currency); ?>
</span>
<?php if ($inStock) : ?>
<a href="<?php echo $addCartLink; ?>" class="btn btn-sm btn-primary">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
</a>
<?php else : ?>
<span class="badge bg-secondary"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php // Pagination ?>
<?php $baseUrl = 'index.php?option=com_mokodolijoomshop&view=products'
. ($this->categoryId ? '&category_id=' . $this->categoryId : ''); ?>
<nav class="mt-4" aria-label="<?php echo Text::_('JLIB_HTML_PAGINATION'); ?>">
<ul class="pagination justify-content-center">
<?php if ($this->page > 0) : ?>
<li class="page-item">
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page - 1)); ?>">
&laquo; <?php echo Text::_('JPREV'); ?>
</a>
</li>
<?php endif; ?>
<?php if (\count($this->items) >= $this->perPage) : ?>
<li class="page-item">
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page + 1)); ?>">
<?php echo Text::_('JNEXT'); ?> &raquo;
</a>
</li>
<?php endif; ?>
</ul>
</nav>
<?php endif; ?>
</div>
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKODOLIJOOMSHOP_PRODUCTS">
<message>COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC</message>
</layout>
<fields name="params">
<fieldset name="basic" label="COM_MOKODOLIJOOMSHOP_FIELDSET_SHOP">
<field
name="products_per_page"
type="number"
label="COM_MOKODOLIJOOMSHOP_FIELD_PRODUCTS_PER_PAGE"
default=""
min="1"
max="100"
hint="COM_MOKODOLIJOOMSHOP_USE_GLOBAL"
/>
<field
name="sort_order"
type="list"
label="COM_MOKODOLIJOOMSHOP_SORT_BY"
default=""
>
<option value="">COM_MOKODOLIJOOMSHOP_USE_GLOBAL</option>
<option value="ref_asc">COM_MOKODOLIJOOMSHOP_SORT_REF_ASC</option>
<option value="price_asc">COM_MOKODOLIJOOMSHOP_SORT_PRICE_ASC</option>
<option value="price_desc">COM_MOKODOLIJOOMSHOP_SORT_PRICE_DESC</option>
<option value="newest">COM_MOKODOLIJOOMSHOP_SORT_NEWEST</option>
</field>
</fieldset>
</fields>
</metadata>