65 Commits

Author SHA1 Message Date
jmiller 1fcef28b97 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 09:37:31 +00:00
jmiller 05e0428bc7 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 03:11:18 +00:00
jmiller 915663694c chore: add .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-02 23:47:54 +00:00
jmiller f69fd34e0b chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-02 21:52:08 +00:00
Moko Consulting 8f6ff4be16 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:28 +00:00
Moko Consulting 75ff48005a chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:28 +00:00
Moko Consulting 5ab47f9dd3 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:27 +00:00
gitea-actions[bot] 6ff1d5c478 chore(ci): remove update-server.yml for update server migration [skip ci] 2026-05-31 03:43:22 +00:00
gitea-actions[bot] eea11a2da6 chore(ci): remove cascade-dev.yml for update server migration [skip ci] 2026-05-31 03:43:21 +00:00
gitea-actions[bot] 1a55c7f9ab chore(ci): remove auto-bump.yml for update server migration [skip ci] 2026-05-31 03:43:20 +00:00
gitea-actions[bot] 7ec525803a chore(ci): remove pre-release.yml for update server migration [skip ci] 2026-05-31 03:43:18 +00:00
gitea-actions[bot] f5ce2498d7 chore(ci): remove auto-release.yml for update server migration [skip ci] 2026-05-31 03:43:16 +00:00
jmiller 235d8aaec3 chore: sync .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] 2026-05-31 01:46:02 +00:00
jmiller bd7fbb55e5 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-05-30 16:03:50 +00:00
jmiller 8041b1def2 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 15:04:54 +00:00
jmiller 42bdd4de33 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 15:02:18 +00:00
jmiller 1b04615ec2 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 01:17:04 +00:00
jmiller 2d5195603f chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-29 10:32:31 +00:00
jmiller db51aec0c4 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-28 20:51:41 +00:00
jmiller 71b649849d chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:46:50 +00:00
jmiller d8b467dd18 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:28:56 +00:00
jmiller a7d5d801fd chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-28 20:09:42 +00:00
jmiller 1180bd9047 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-28 20:06:33 +00:00
jmiller 362480a7ed chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:03:20 +00:00
Moko Consulting 66f7ebd369 fix(workflows): rename remaining old secrets in repo-specific workflows [skip bump]
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-05-28 14:44:13 -05:00
Moko Consulting e11f177215 fix(workflows): GITHUB_TOKEN→GH_MIRROR_TOKEN (reserved name) [skip bump]
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-05-28 14:35:34 -05:00
Moko Consulting df8b2a90d9 chore(workflows): sync all universal workflows from moko-platform [skip bump]
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-05-28 14:25:08 -05:00
Moko Consulting 7d10b89865 refactor(workflows): rename secrets MOKOGITEA_TOKEN/GITHUB_TOKEN, use x-access-token [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Joomla: Repo Health / Access control (push) Successful in 1s
Joomla: Repo Health / Release configuration (push) Has been cancelled
Joomla: Repo Health / Scripts governance (push) Has been cancelled
Joomla: Repo Health / Repository health (push) Has been cancelled
2026-05-28 14:23:30 -05:00
Moko Consulting 4921d8d7c4 fix(workflows): proper suffix handling — use version_set_platform instead of sed [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Joomla: Repo Health / Access control (push) Successful in 3s
Joomla: Repo Health / Release configuration (push) Has been cancelled
Joomla: Repo Health / Scripts governance (push) Has been cancelled
Joomla: Repo Health / Repository health (push) Has been cancelled
2026-05-28 14:15:01 -05:00
Moko Consulting ce729cc072 chore: trigger update-server workflow for version suffix [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Joomla: Repo Health / Access control (push) Successful in 1s
Joomla: Repo Health / Release configuration (push) Has been cancelled
Joomla: Repo Health / Scripts governance (push) Has been cancelled
Joomla: Repo Health / Repository health (push) Has been cancelled
2026-05-28 14:02:33 -05:00
Moko Consulting 2836360f73 feat(workflows): append stability suffix to manifest versions [skip bump]
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Joomla: Repo Health / Access control (push) Successful in 2s
Joomla: Repo Health / Release configuration (push) Has been cancelled
Joomla: Repo Health / Scripts governance (push) Has been cancelled
Joomla: Repo Health / Repository health (push) Has been cancelled
2026-05-28 13:41:19 -05:00
jmiller afb711bc1d chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:51:24 +00:00
jmiller a305d423c3 chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:50:13 +00:00
jmiller 125e505492 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:49:00 +00:00
jmiller f9a3fe3639 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:37:34 +00:00
jmiller b34c323d81 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:36:06 +00:00
jmiller 80c94bbc27 chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:25:45 +00:00
jmiller daa6d91fd5 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:24:29 +00:00
jmiller bb3e0636ef chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:13:50 +00:00
jmiller 2c4227656e chore(ci): add auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:12:38 +00:00
jmiller d154d2d309 fix(ci): use release_package.php for Joomla package builds [skip ci] 2026-05-26 19:54:33 +00:00
jmiller a15ee9c8bd chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 19:35:07 +00:00
jmiller 3ff1a3464b chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 19:35:06 +00:00
jmiller fccac1a510 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 17:36:23 +00:00
jmiller 7e175d8af2 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 17:34:53 +00:00
jmiller 8d87f3920a Merge pull request 'chore: cascade main → dev (ea0fe51) [skip ci]' (#29) from main into dev
chore: cascade main → dev [skip ci]
2026-05-22 02:33:21 +00:00
Jonathan Miller ea0fe519b2 docs: update CHANGELOG for v1.0.0-dev.1
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 21:31:49 -05:00
Jonathan Miller 9b0de50cae 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:31:49 -05:00
Jonathan Miller 8b496dc26b 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 21:31:49 -05:00
Jonathan Miller b32aa8b573 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 21:31:48 -05:00
Jonathan Miller dcc652157c 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 21:31:48 -05:00
Jonathan Miller df59df6ea9 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 21:31:47 -05:00
Jonathan Miller 9e30eb787b 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 21:31:47 -05:00
Jonathan Miller 32539543df 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 21:31:46 -05:00
Jonathan Miller b4b8b026e7 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 21:31:46 -05:00
Jonathan Miller c1c0aef952 merge: feature/initial-implementation into dev
Brings all 27 feature implementations into the dev branch for testing.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 21:27:22 -05:00
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
85 changed files with 8532 additions and 3124 deletions
File diff suppressed because it is too large Load Diff
-213
View File
@@ -1,213 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/cascade-dev.yml.template
# VERSION: 02.00.00
# BRIEF: Forward-merge main → all open branches after every push to main
#
# +========================================================================+
# | CASCADE MAIN → ALL BRANCHES |
# +========================================================================+
# | |
# | Triggers on every push to main (PR merges, bot commits, etc.) |
# | |
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
# | 2. For each: create PR (main → branch), auto-merge if clean |
# | 3. On conflict: leave PR open for manual resolution |
# | |
# +========================================================================+
name: "Universal: Cascade Main → Dev"
on:
push:
branches:
- main
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
pull-requests: write
jobs:
cascade:
name: Cascade main → branches
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip cascade]')
steps:
- name: Discover target branches
id: branches
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Fetch all branches (paginated)
PAGE=1
ALL_BRANCHES=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
PAGE=$((PAGE + 1))
done
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
TARGETS=""
for BRANCH in $ALL_BRANCHES; do
case "$BRANCH" in
dev|dev/*|rc/*|beta/*|alpha/*)
TARGETS="$TARGETS $BRANCH"
;;
esac
done
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
if [ -z "$TARGETS" ]; then
echo "targets=" >> "$GITHUB_OUTPUT"
echo "️ No cascade target branches found"
else
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$TARGETS" | wc -w)
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
fi
- name: Cascade to all target branches
if: steps.branches.outputs.targets != ''
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
SHORT_SHA="${GITHUB_SHA:0:7}"
TARGETS="${{ steps.branches.outputs.targets }}"
SUCCESS=0
CONFLICTS=0
SKIPPED=0
FAILED=0
for BRANCH in $TARGETS; do
echo ""
echo "═══ main → ${BRANCH} ═══"
# Check if branch is already up to date
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
RESPONSE=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/compare/${ENCODED_BRANCH}...main")
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
if [ "$AHEAD" -eq 0 ]; then
echo " ✅ Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " ️ main is ${AHEAD} commit(s) ahead"
# Check for existing cascade PR
EXISTING=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
PR_NUMBER=""
if [ "$EXISTING_COUNT" -gt 0 ]; then
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
echo " ️ Reusing existing PR #${PR_NUMBER}"
else
# Create cascade PR
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
\"head\": \"main\",
\"base\": \"${BRANCH}\"
}" \
"${API}/pulls")
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
BODY=$(echo "$PR_RESPONSE" | sed '$d')
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
FAILED=$((FAILED + 1))
continue
fi
echo " ✅ Created PR #${PR_NUMBER}"
fi
# Try auto-merge
PR_DATA=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/pulls/${PR_NUMBER}")
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
if [ "$MERGEABLE" != "true" ]; then
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
continue
fi
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"Do\": \"merge\",
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
\"delete_branch_after_merge\": false
}" \
"${API}/pulls/${PR_NUMBER}/merge")
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
echo " ✅ Merged — ${BRANCH} is in sync"
SUCCESS=$((SUCCESS + 1))
else
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
fi
done
# Summary
echo ""
echo "════════════════════════════════════════"
echo " ✅ Merged: ${SUCCESS}"
echo " ⚠️ Conflicts: ${CONFLICTS}"
echo " ⏭️ Up to date: ${SKIPPED}"
echo " ❌ Failed: ${FAILED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
exit 1
fi
-386
View File
@@ -1,386 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.00.00
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability }})"
runs-on: release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
fi
- 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)
[ -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)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
- name: Resolve metadata
id: meta
run: |
STABILITY="${{ inputs.stability }}"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Read and bump patch version (with rollover)
CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
[ -z "$CURRENT" ] && CURRENT="00.00.00"
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
MINOR=$(echo "$CURRENT" | cut -d. -f2)
PATCH=$(echo "$CURRENT" | cut -d. -f3)
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
NEW_PATCH=$((10#$PATCH + 1))
NEW_MINOR=$((10#$MINOR))
NEW_MAJOR=$((10#$MAJOR))
if [ $NEW_PATCH -gt 99 ]; then
NEW_PATCH=0
NEW_MINOR=$((NEW_MINOR + 1))
fi
if [ $NEW_MINOR -gt 99 ]; then
NEW_MINOR=0
NEW_MAJOR=$((NEW_MAJOR + 1))
fi
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
TODAY=$(date +%Y-%m-%d)
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
# Update README.md
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
# Update platform-specific manifest
PLATFORM="${{ steps.platform.outputs.platform }}"
MANIFEST="${{ steps.platform.outputs.manifest }}"
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
case "$PLATFORM" in
joomla)
if [ -n "$MANIFEST" ]; then
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
fi
;;
*) ;;
esac
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element (platform-aware)
case "$PLATFORM" in
joomla)
MANIFEST="${{ steps.platform.outputs.manifest }}"
EXT_ELEMENT=""
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
dolibarr)
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
if [ -n "$MOD_FILE" ]; then
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
else
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
;;
*)
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
;;
esac
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Build package
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::error::No src/ or htdocs/ directory"
exit 1
fi
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/
- name: Create ZIP
id: zip
run: |
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
cd build/package
zip -r "../${ZIP_NAME}" .
cd ..
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
- name: Create or replace Gitea release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
BRANCH=$(git branch --show-current)
BODY="## ${VERSION} ($(date +%Y-%m-%d))
**Channel:** ${STABILITY}
**SHA-256:** \`${SHA256}\`"
# Delete existing release
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/tags/${TAG}" 2>/dev/null || true
fi
# Create release
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/releases" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg target "$BRANCH" \
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
--arg body "$BODY" \
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
)" | jq -r '.id')
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
# Upload ZIP
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
--data-binary "@build/${ZIP_NAME}"
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
STABILITY="${{ steps.meta.outputs.stability }}"
VERSION="${{ steps.meta.outputs.version }}"
SHA256="${{ steps.zip.outputs.sha256 }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
TAG="${{ steps.meta.outputs.tag }}"
DATE=$(date +%Y-%m-%d)
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
python3 << 'PYEOF'
import re, os
stability = os.environ["PY_STABILITY"]
version = os.environ["PY_VERSION"]
sha256 = os.environ["PY_SHA256"]
zip_name = os.environ["PY_ZIP_NAME"]
tag = os.environ["PY_TAG"]
date = os.environ["PY_DATE"]
gitea_org = os.environ["PY_GITEA_ORG"]
gitea_repo = os.environ["PY_GITEA_REPO"]
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
with open("updates.xml", "r") as f:
content = f.read()
# Map stability to XML tag name
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
xml_tag = tag_map.get(stability, stability)
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
match = re.search(pattern, content, re.DOTALL)
if match:
block = match.group(1)
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
if "<sha256>" in updated:
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
else:
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
content = content.replace(block, updated)
print(f"Updated {xml_tag} channel: version={version}")
else:
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit and push to current branch
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
# Sync updates.xml to main and dev (whichever isn't current)
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml → ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
STABILITY="${{ steps.meta.outputs.stability }}"
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
case "$STABILITY" in
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
beta) TAGS_TO_DELETE="alpha development" ;;
alpha) TAGS_TO_DELETE="development" ;;
*) TAGS_TO_DELETE="" ;;
esac
[ -z "$TAGS_TO_DELETE" ] && exit 0
for TAG in $TAGS_TO_DELETE; do
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
fi
done
-464
View File
@@ -1,464 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Joomla
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/update-server.yml.template
# VERSION: 04.06.00
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
#
# Writes updates.xml with multiple <update> entries:
# - <tag>stable</tag> on push to main (from auto-release)
# - <tag>rc</tag> on push to rc/**
# - <tag>development</tag> on push to dev or dev/**
#
# Joomla filters by user's "Minimum Stability" setting.
name: "Joomla: Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update updates.xml
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN }}
fetch-depth: 0
- name: Setup MokoStandards tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
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 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Generate updates.xml entry
id: update
run: |
BRANCH="${{ github.ref_name }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Auto-bump patch on all branches (dev, alpha, beta, rc)
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
if [ -n "$BUMPED" ]; then
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
git add -A
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
git push 2>/dev/null || true
fi
# Determine stability from branch or input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
STABILITY="development"
else
STABILITY="stable"
fi
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
# Parse manifest (portable — no grep -P)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "No Joomla manifest found — skipping"
exit 0
fi
# Extract fields using sed (works on all runners)
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
# Fallbacks
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
# Derive element if not in manifest: try XML filename, then repo name
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
# Use manifest version if README version is empty
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
CLIENT_TAG=""
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
FOLDER_TAG=""
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
PHP_TAG=""
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
# Version suffix for non-stable
DISPLAY_VERSION="$VERSION"
case "$STABILITY" in
development) DISPLAY_VERSION="${VERSION}-dev" ;;
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
esac
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
# Each stability level has its own release tag
case "$STABILITY" in
development) RELEASE_TAG="development" ;;
alpha) RELEASE_TAG="alpha" ;;
beta) RELEASE_TAG="beta" ;;
rc) RELEASE_TAG="release-candidate" ;;
*) RELEASE_TAG="v${MAJOR}" ;;
esac
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
# -- Build install packages (ZIP + tar.gz) --------------------
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ -d "$SOURCE_DIR" ]; then
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
cd "$SOURCE_DIR"
zip -r "/tmp/${PACKAGE_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*' .
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
# Ensure release exists on Gitea
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
# Create release
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases" \
-d "$(python3 -c "import json; print(json.dumps({
'tag_name': '${RELEASE_TAG}',
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
'body': '${STABILITY} release',
'prerelease': True,
'target_commitish': 'main'
}))")" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
fi
if [ -n "$RELEASE_ID" ]; then
# Delete existing assets with same name before uploading
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
ASSET_ID=$(echo "$ASSETS" | python3 -c "
import sys,json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '${ASSET_FILE}':
print(a['id']); break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
fi
done
# Upload both formats
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${PACKAGE_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${TAR_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
fi
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
else
SHA256=""
fi
# -- Build the new entry (canonical format matching release.yml) --
NEW_ENTRY=""
NEW_ENTRY="${NEW_ENTRY} <update>\n"
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
NEW_ENTRY="${NEW_ENTRY} </update>"
# -- Write new entry to temp file --------------------------------
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
# -- Merge into updates.xml ----------------------------------------
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
TARGETS=""
for entry in $CASCADE_MAP; do
key="${entry%%:*}"
vals="${entry#*:}"
if [ "$key" = "${STABILITY}" ]; then
TARGETS="$vals"
break
fi
done
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
echo "Cascade: ${STABILITY} → ${TARGETS}"
# Create updates.xml if missing
if [ ! -f "updates.xml" ]; then
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
printf '%s\n' "<updates>" >> updates.xml
printf '%s\n' "</updates>" >> updates.xml
fi
# Update existing blocks or create missing ones
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
python3 << 'PYEOF'
import re, os
targets = os.environ["PY_TARGETS"].split(",")
version = os.environ["PY_VERSION"]
date = os.environ["PY_DATE"]
with open("updates.xml") as f:
content = f.read()
with open("/tmp/new_entry.xml") as f:
new_entry_template = f.read()
for tag in targets:
tag = tag.strip()
# Build entry with this tag's name
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
# Try to find existing block (handles both single-line and multi-line <tags>)
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
match = re.search(block_pattern, content, re.DOTALL)
if match:
# Update in place — replace entire block
content = content.replace(match.group(1), new_entry.strip())
print(f" UPDATED: <tag>{tag}</tag> → {version}")
else:
# Create — insert before </updates>
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
print(f" CREATED: <tag>{tag}</tag> → {version}")
# Clean up excessive blank lines
content = re.sub(r"\n{3,}", "\n\n", content)
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
# -- Sync updates.xml to main (for non-main branches) ----------------------
- name: Sync updates.xml to main
if: github.ref_name != 'main'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GA_TOKEN="${{ secrets.GA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
CONTENT=$(base64 -w0 updates.xml)
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/contents/updates.xml" \
-d "$(python3 -c "import json; print(json.dumps({
'content': '${CONTENT}',
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
'branch': 'main'
}))")" > /dev/null 2>&1 \
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
else
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# -- Permission check: admin or maintain role required --------
ACTOR="${{ github.actor }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
@@ -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>
+283
View File
@@ -0,0 +1,283 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# 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
#
# +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +========================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
branches:
- main
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: false
type: choice
default: release
options:
- release
- promote-rc
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if ! command -v composer &> /dev/null; then
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
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"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
- name: Rename branch to rc
run: |
php /tmp/moko-platform-api/cli/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
name: Build & Release Pipeline
runs-on: release
if: >-
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found — aborting release"
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
# Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then
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
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"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
- name: "Publish stable release"
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch and recreate dev 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.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# 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}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version 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.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
@@ -43,9 +43,9 @@ jobs:
- name: Clone MokoStandards
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: |
git clone --depth 1 --branch main --quiet \
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
@@ -53,7 +53,7 @@ jobs:
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -346,7 +346,7 @@ jobs:
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -391,7 +391,7 @@ jobs:
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --optimize-autoloader
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
@@ -33,17 +33,17 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Delete merged branches
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
@@ -42,10 +42,10 @@ jobs:
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# INGROUP: moko-platform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
@@ -18,7 +18,6 @@ on:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
- "Cascade Main → Dev"
types:
- completed
@@ -1,224 +1,277 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
alpha/*|beta/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Pre-release branches must target 'dev', not '${BASE}'"
fi
;;
rc/*)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Release candidate branches must target 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
# Parse manifest for platform detection
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Changelog Gate ────────────────────────────────────────────────────
changelog:
name: Changelog Updated
runs-on: ubuntu-latest
if: github.base_ref == 'main'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check CHANGELOG.md was updated
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
if git diff --name-only "$BASE" "$HEAD" | grep -q "^CHANGELOG.md$"; then
echo "CHANGELOG.md updated"
else
# Allow [skip changelog] in PR title or body
PR_TITLE="${{ github.event.pull_request.title }}"
PR_BODY="${{ github.event.pull_request.body }}"
if echo "$PR_TITLE $PR_BODY" | grep -qi "\[skip changelog\]"; then
echo "::warning::Changelog skip requested via [skip changelog]"
exit 0
fi
echo "::error::CHANGELOG.md must be updated before merging to main. Add [skip changelog] to the PR title to bypass."
exit 1
fi
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
@@ -80,3 +80,19 @@ jobs:
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
- name: Joomla version audit
if: always()
run: |
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
echo "$JOOMLA_SITES" > /tmp/sites.json
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
rm -f /tmp/sites.json
else
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
fi
env:
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
+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)
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
@@ -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>
+74
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,11 +133,79 @@
<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>
</extension>
@@ -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>