42 Commits

Author SHA1 Message Date
Moko Consulting a713a44949 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
2026-06-02 20:38:26 +00:00
Moko Consulting 11da5b8914 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
2026-06-02 20:38:26 +00:00
Moko Consulting 076a2d5a27 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
2026-06-02 20:38:25 +00:00
jmiller 1d769e6a78 chore: sync .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] 2026-05-31 01:42:38 +00:00
jmiller 4d85794f88 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-31 01:10:40 +00:00
jmiller 6790d87f28 chore: add .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-05-30 16:02:07 +00:00
jmiller 16bcef3404 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-30 15:00:16 +00:00
jmiller 1e83888b84 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 14:56:44 +00:00
jmiller d2503101cd chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 14:54:46 +00:00
jmiller a7ae6b9af0 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 05:51:52 +00:00
jmiller 313d8659e0 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 03:41:43 +00:00
jmiller 74a5473c14 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 01:15:26 +00:00
jmiller dc2c9f5f2b chore: add .mokogitea/branch-protection.yml from moko-platform [skip ci] 2026-05-29 10:30:40 +00:00
jmiller 97bd5610c1 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-29 10:28:06 +00:00
jmiller 7aff833d01 chore: add .mokogitea/workflows/branch-cleanup.yml from moko-platform [skip ci] 2026-05-29 10:26:29 +00:00
jmiller bdfdfec034 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-29 10:25:01 +00:00
jmiller 70e542a4d1 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-29 10:23:32 +00:00
jmiller 445f2b03ba chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-28 20:54:16 +00:00
jmiller 0bb71a80f8 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-28 20:49:09 +00:00
jmiller 7cdc4d96e4 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:44:25 +00:00
jmiller eb9d2a277d chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:38:31 +00:00
gitea-actions[bot] c2788dc2ac feat(ci): add version branch creation on stable release [skip ci] 2026-05-27 02:19:08 +00:00
jmiller 162675d577 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:51:24 +00:00
jmiller 4407605372 chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:50:13 +00:00
jmiller b0c9c0230d chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:49:01 +00:00
jmiller 984f0b9a1d chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:37:34 +00:00
jmiller ae5fddcc79 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:36:07 +00:00
jmiller da80e6c54e chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:25:45 +00:00
jmiller c96f0754ca chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:24:29 +00:00
jmiller 71096244fe chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:13:51 +00:00
jmiller 8438be07df chore(ci): add auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:12:38 +00:00
jmiller b20212b400 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-26 20:13:17 +00:00
jmiller 9f16b73572 chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-26 20:11:24 +00:00
jmiller e544482e4e fix(ci): use release_package.php for Joomla package builds [skip ci] 2026-05-26 19:54:33 +00:00
jmiller 85f094fa8e chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 19:35:07 +00:00
jmiller 5ad3eb9f88 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 19:35:06 +00:00
jmiller 2eecbdc93c chore: add .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-26 19:04:37 +00:00
jmiller fabf93554a chore: add .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-26 03:08:04 +00:00
jmiller b28fa9d7dc chore: add .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-26 03:06:07 +00:00
jmiller 206d195564 feat(ci): add issue-branch.yml [skip ci] 2026-05-25 05:12:58 +00:00
jmiller bc7149d270 feat(ci): add cascade-dev.yml for main -> dev auto-sync [skip ci] 2026-05-25 04:25:21 +00:00
jmiller 1987adf3fa chore: update CLAUDE.md to reference .mokogitea/ [skip ci]
Authored-by: Moko Consulting
2026-05-21 20:09:00 +00:00
96 changed files with 5545 additions and 10071 deletions
@@ -1,9 +1,14 @@
<?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>MokoDoliJoomShop</name>
<name>Template-Joomla</name>
<org>MokoConsulting</org>
<description>Joomla storefront component backed by Dolibarr products and invoicing</description>
<description>Template repository for Joomla extensions (plugins, modules, components, templates)</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
@@ -13,7 +18,7 @@
</governance>
<build>
<language>PHP</language>
<package-type>joomla-component</package-type>
<package-type>joomla-extension</package-type>
<entry-point>src/</entry-point>
</build>
</moko-platform>
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
# 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
+224
View File
@@ -0,0 +1,224 @@
# 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
+386
View File
@@ -0,0 +1,386 @@
# 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
+766
View File
@@ -0,0 +1,766 @@
# ============================================================================
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 04.06.00
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: "Joomla: Repo Health"
concurrency:
group: repo-health-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
profile:
description: 'Validation profile: all, release, scripts, or repo'
required: true
default: all
type: choice
options:
- all
- release
- scripts
- repo
pull_request:
push:
permissions:
contents: read
env:
# Release policy - Repository Variables Only
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
REPO_DISALLOWED_DIRS:
REPO_DISALLOWED_FILES: TODO.md,todo.md
# Extended checks toggles
EXTENDED_CHECKS: "true"
# File / directory variables
DOCS_INDEX: docs/docs-index.md
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .gitea/workflows
SHELLCHECK_PATTERN: '*.sh'
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
access_check:
name: Access control
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
allowed: ${{ steps.perm.outputs.allowed }}
permission: ${{ steps.perm.outputs.permission }}
steps:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
ALLOWED=false
PERMISSION=unknown
METHOD=""
# Hardcoded authorized users — always allowed
case "$ACTOR" in
jmiller|gitea-actions[bot])
ALLOWED=true
PERMISSION=admin
METHOD="hardcoded allowlist"
;;
*)
# Detect platform and check permissions via API
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
ALLOWED=true
fi
METHOD="collaborator API"
;;
esac
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
{
echo "## Access Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${ALLOWED} |"
echo ""
if [ "$ALLOWED" = "true" ]; then
echo "${ACTOR} authorized (${METHOD})"
else
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
fi
} >> "${GITHUB_STEP_SUMMARY}"
- name: Deny execution when not permitted
if: ${{ steps.perm.outputs.allowed != 'true' }}
run: |
set -euo pipefail
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1
release_config:
name: Release configuration
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Guardrails release vars
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes release validation'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
missing=()
missing_optional=()
for k in "${required[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing+=("${k}")
done
for k in "${optional[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing_optional+=("${k}")
done
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Variable | Status |'
printf '%s\n' '|---|---|'
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repository variables'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#missing[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repository variables'
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
{
printf '%s\n' '### Repository variables validation result'
printf '%s\n' 'Status: OK'
printf '%s\n' 'All required repository variables present.'
printf '%s\n' ''
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
scripts_governance:
name: Scripts governance
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Scripts folder checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes scripts governance'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
if [ ! -d "${SCRIPT_DIR}" ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' 'Status: OK (advisory)'
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
missing_dirs=()
unapproved_dirs=()
for d in "${required_dirs[@]}"; do
req="${d%/}"
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
done
while IFS= read -r d; do
allowed=false
for a in "${allowed_dirs[@]}"; do
a_norm="${a%/}"
[ "${d%/}" = "${a_norm}" ] && allowed=true
done
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Area | Status | Notes |'
printf '%s\n' '|---|---|---|'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
else
printf '%s\n' '| Required directories | OK | All required subfolders present |'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
else
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
fi
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
printf '\n'
if [ "${#missing_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Missing required script directories:'
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Missing required script directories: none.'
printf '\n'
fi
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
printf '%s\n' 'Unapproved script directories detected:'
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
else
printf '%s\n' 'Unapproved script directories detected: none.'
printf '\n'
fi
printf '%s\n' 'Scripts governance completed in advisory mode.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
repo_health:
name: Repository health
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Repository health checks
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes repository health'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
# Source directory: src/ or htdocs/ (either is valid)
if [ -d "src" ]; then
SOURCE_DIR="src"
elif [ -d "htdocs" ]; then
SOURCE_DIR="htdocs"
else
missing_required+=("src/ or htdocs/ (source directory required)")
fi
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
missing_required=()
missing_optional=()
for item in "${required_artifacts[@]}"; do
if printf '%s' "${item}" | grep -q '/$'; then
d="${item%/}"
[ ! -d "${d}" ] && missing_required+=("${item}")
else
[ ! -f "${item}" ] && missing_required+=("${item}")
fi
done
for f in "${optional_files[@]}"; do
if printf '%s' "${f}" | grep -q '/$'; then
d="${f%/}"
[ ! -d "${d}" ] && missing_optional+=("${f}")
else
[ ! -f "${f}" ] && missing_optional+=("${f}")
fi
done
for d in "${disallowed_dirs[@]}"; do
d_norm="${d%/}"
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
done
for f in "${disallowed_files[@]}"; do
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
done
git fetch origin --prune
dev_paths=()
dev_branches=()
while IFS= read -r b; do
name="${b#origin/}"
if [ "${name}" = 'dev' ]; then
dev_branches+=("${name}")
else
dev_paths+=("${name}")
fi
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
if [ "${#dev_paths[@]}" -eq 0 ]; then
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
fi
if [ "${#dev_branches[@]}" -gt 0 ]; then
missing_required+=("invalid branch dev (must be dev/<version>)")
fi
content_warnings=()
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
fi
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
fi
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
content_warnings+=("LICENSE does not look like a GPL text")
fi
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
content_warnings+=("README.md missing expected brand keyword")
fi
export PROFILE_RAW="${profile}"
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
report_json="$(python3 - <<'PY'
import json
import os
profile = os.environ.get('PROFILE_RAW') or 'all'
missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
out = {
'profile': profile,
'missing_required': [x for x in missing_required if x],
'missing_optional': [x for x in missing_optional if x],
'content_warnings': [x for x in content_warnings if x],
}
print(json.dumps(out, indent=2))
PY
)"
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Metric | Value |'
printf '%s\n' '|---|---|'
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
printf '\n'
printf '%s\n' '### Guardrails report (JSON)'
printf '%s\n' '```json'
printf '%s\n' "${report_json}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_required[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repo artifacts'
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repo artifacts'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#content_warnings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Repo content warnings'
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
# -- Joomla-specific checks --
joomla_findings=()
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
if [ -z "${MANIFEST}" ]; then
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
else
if ! grep -qP '<version>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <version> tag missing")
fi
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
joomla_findings+=("XML manifest: type attribute missing or invalid")
fi
if ! grep -qP '<name>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <name> tag missing")
fi
if ! grep -qP '<author>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <author> tag missing")
fi
if ! grep -qP '<namespace' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
fi
fi
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
if [ "${INI_COUNT}" -eq 0 ]; then
joomla_findings+=("No .ini language files found")
fi
if [ ! -f 'updates.xml' ]; then
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
fi
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
for dir in "${INDEX_DIRS[@]}"; do
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
fi
done
if [ "${#joomla_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' '| Check | Status |'
printf '%s\n' '|---|---|'
for f in "${joomla_findings[@]}"; do
printf '%s\n' "| ${f} | Warning |"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
else
{
printf '%s\n' '### Joomla extension checks'
printf '%s\n' 'All Joomla-specific checks passed.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
extended_enabled="${EXTENDED_CHECKS:-true}"
extended_findings=()
if [ "${extended_enabled}" = 'true' ]; then
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
:
else
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
fi
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
if [ -n "${bad_refs}" ]; then
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
{
printf '%s\n' '### Workflow pinning advisory'
printf '%s\n' 'Found uses: entries pinned to main/master:'
printf '%s\n' '```'
printf '%s\n' "${bad_refs}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -f "${DOCS_INDEX}" ]; then
missing_links="$(python3 - <<'PY'
import os
import re
idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
base = os.getcwd()
bad = []
pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
with open(idx, 'r', encoding='utf-8') as f:
for line in f:
for m in pat.findall(line):
link = m.strip()
if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
continue
if link.startswith('/'):
rel = link.lstrip('/')
else:
rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
rel = rel.split('#', 1)[0]
rel = rel.split('?', 1)[0]
if not rel:
continue
p = os.path.join(base, rel)
if not os.path.exists(p):
bad.append(rel)
print('\n'.join(sorted(set(bad))))
PY
)"
if [ -n "${missing_links}" ]; then
extended_findings+=("docs/docs-index.md contains broken relative links")
{
printf '%s\n' '### Docs index link integrity'
printf '%s\n' 'Broken relative links:'
while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
if [ -d "${SCRIPT_DIR}" ]; then
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y shellcheck >/dev/null
fi
sc_out=''
while IFS= read -r shf; do
[ -z "${shf}" ] && continue
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
if [ -n "${out_one}" ]; then
sc_out="${sc_out}${out_one}\n"
fi
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
if [ -n "${sc_out}" ]; then
extended_findings+=("ShellCheck warnings detected (advisory)")
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
{
printf '%s\n' '### ShellCheck (advisory)'
printf '%s\n' '```'
printf '%s\n' "${sc_head}"
printf '%s\n' '```'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
spdx_missing=()
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
spdx_args=()
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
while IFS= read -r f; do
[ -z "${f}" ] && continue
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
spdx_missing+=("${f}")
fi
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
if [ "${#spdx_missing[@]}" -gt 0 ]; then
extended_findings+=("SPDX header missing in some tracked files (advisory)")
{
printf '%s\n' '### SPDX header advisory'
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
stale_cutoff_days=180
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
if [ -n "${stale_branches}" ]; then
extended_findings+=("Stale remote branches detected (advisory)")
{
printf '%s\n' '### Git hygiene advisory'
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
fi
{
printf '%s\n' '### Guardrails coverage matrix'
printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release variables | OK | Repository variables validation |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
if [ "${extended_enabled}" = 'true' ]; then
if [ "${#extended_findings[@]}" -gt 0 ]; then
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
else
printf '%s\n' '| Extended checks | OK | No findings |'
fi
else
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
fi
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
{
printf '%s\n' '### Extended findings (advisory)'
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
+464
View File
@@ -0,0 +1,464 @@
# 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
+251
View File
@@ -0,0 +1,251 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/branch-protection.yml
# BRIEF: Apply standardised branch protection rules to all governed repositories
#
# +========================================================================+
# | BRANCH PROTECTION SETUP |
# +========================================================================+
# | |
# | Applies protection rules for: main, dev, rc, beta, alpha |
# | |
# | main — Require PR, block rejected reviews, no force push |
# | dev — Allow push, no force push, no delete |
# | rc — Allow push, no force push, no delete |
# | beta — Allow push, no force push, no delete |
# | alpha — Allow push, no force push, no delete |
# | |
# | jmiller has override authority on all branches. |
# | |
# +========================================================================+
name: Branch Protection Setup
on:
schedule:
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Preview mode (no changes)'
required: false
type: boolean
default: false
repos:
description: 'Comma-separated repo names (empty = all governed repos)'
required: false
type: string
default: ''
env:
GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting
permissions:
contents: read
jobs:
protect:
name: Apply Branch Protection Rules
runs-on: ubuntu-latest
steps:
- name: Determine target repos
id: repos
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then
# User-specified repos
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
else
# Fetch all org repos
PAGE=1
REPOS=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
REPOS="$REPOS $BATCH"
PAGE=$((PAGE + 1))
done
# Filter out excluded repos
FILTERED=""
for REPO in $REPOS; do
SKIP=false
for EX in $EXCLUDE; do
if [ "$REPO" = "$EX" ]; then
SKIP=true
break
fi
done
if [ "$SKIP" = "false" ]; then
FILTERED="$FILTERED $REPO"
fi
done
REPOS="$FILTERED"
fi
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$REPOS" | wc -w)
echo "📋 Target repos (${COUNT}): $REPOS"
- name: Apply protection rules
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: |
API="${GITEA_URL}/api/v1"
REPOS="${{ steps.repos.outputs.repos }}"
SUCCESS=0
FAILED=0
SKIPPED=0
# ── Rule definitions ──────────────────────────────────────
# Only the CI bot (jmiller token) can push directly.
# All human contributors must use PRs.
# Force push disabled on all branches.
RULE_MAIN='{
"rule_name": "main",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"dismiss_stale_approvals": true,
"block_on_rejected_reviews": true,
"block_on_outdated_branch": false,
"priority": 1
}'
RULE_DEV='{
"rule_name": "dev",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 2
}'
RULE_RC='{
"rule_name": "rc",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 3
}'
RULE_BETA='{
"rule_name": "beta",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 4
}'
RULE_ALPHA='{
"rule_name": "alpha",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 5
}'
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
# ── Apply rules to each repo ──────────────────────────────
for REPO in $REPOS; do
echo ""
echo "═══ ${REPO} ═══"
for i in "${!RULES[@]}"; do
RULE="${RULES[$i]}"
NAME="${RULE_NAMES[$i]}"
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would apply rule: ${NAME}"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Delete existing rule if present (idempotent recreate)
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
curl -sS -o /dev/null -w "" \
-X DELETE \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
# Create rule
RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$RULE" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
HTTP=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP" = "201" ]; then
echo " ✅ ${NAME}"
SUCCESS=$((SUCCESS + 1))
else
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
FAILED=$((FAILED + 1))
fi
done
done
# ── Summary ───────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo " ✅ Success: ${SUCCESS}"
echo " ❌ Failed: ${FAILED}"
echo " ⏭️ Skipped: ${SKIPPED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
echo "::warning::${FAILED} rule(s) failed to apply"
fi
+66
View File
@@ -0,0 +1,66 @@
# 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/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
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
if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+7 -210
View File
@@ -1,213 +1,10 @@
# 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
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
jobs:
cascade:
name: Cascade main → branches
noop:
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
- run: echo "Cascade disabled — auto-release handles dev recreation"
+73
View File
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
name: Create feature branch
runs-on: ubuntu-latest
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
# Build slug from title: lowercase, replace non-alnum with dash, trim
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
# Check dev branch exists
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API}/branches/dev" 2>/dev/null || echo "000")
if [ "${DEV_EXISTS}" != "200" ]; then
echo "No dev branch -- skipping"
exit 0
fi
# Create branch from dev
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/branches" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
if [ "${HTTP}" = "201" ]; then
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${GITEA_URL}/${{ github.repository }}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/comments" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
echo "Commented on issue #${ISSUE_NUM}"
else
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
fi
+264 -224
View File
@@ -1,224 +1,264 @@
# 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: 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."
+101 -296
View File
@@ -5,14 +5,18 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# 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
# VERSION: 05.01.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
pull_request:
types: [closed]
branches:
- dev
workflow_dispatch:
inputs:
stability:
@@ -35,41 +39,44 @@ env:
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability }})"
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GA_TOKEN }}
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Setup PHP
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
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
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
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
- name: Detect platform
id: platform
run: |
tr -d '[:space:]')| tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
# For packages: prefer pkg_*.xml in src/; fallback to any manifest
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata
- name: Resolve metadata and bump version
id: meta
run: |
STABILITY="${{ inputs.stability }}"
STABILITY="${{ inputs.stability || 'development' }}"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
@@ -78,109 +85,44 @@ jobs:
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"
# Read current version (bump already handled by push workflow)
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
[ -z "$VERSION" ] && VERSION="00.00.01"
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
MINOR=$(echo "$CURRENT" | cut -d. -f2)
PATCH=$(echo "$CURRENT" | cut -d. -f3)
# Strip any existing suffix from version before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# 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))
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
if [ $NEW_PATCH -gt 99 ]; then
NEW_PATCH=0
NEW_MINOR=$((NEW_MINOR + 1))
# Verify version consistency across all files
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Update VERSION variable with suffix
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
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
# For packages: also bump version in all sub-extension manifests
if [ -d "src/packages" ]; then
for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null); do
SUB_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1)
if [ -n "$SUB_VER" ]; then
sed -i "s|<version>${SUB_VER}</version>|<version>${VERSION}</version>|" "$SUB_MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$SUB_MANIFEST"
echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}"
fi
done
fi
;;
dolibarr)
if [ -n "$MOD_FILE" ]; then
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 remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_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 commit -m "chore(version): pre-release bump to ${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
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GITEA_REPO}" --github-output
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
@@ -188,183 +130,52 @@ jobs:
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
MANIFEST="${{ steps.meta.outputs.manifest }}"
EXT_TYPE=""
if [ -n "$MANIFEST" ]; then
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
fi
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
mkdir -p build/package
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
echo "=== Building Joomla PACKAGE (multi-extension) ==="
# 1) ZIP each sub-extension in src/packages/
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
[ ! -d "$ext_dir" ] && continue
EXT_NAME=$(basename "$ext_dir")
echo " Packaging sub-extension: ${EXT_NAME}"
cd "$ext_dir"
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
cd "$OLDPWD"
done
# 2) Copy package-level files (manifest, script, etc.)
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
[ -f "$f" ] && cp "$f" build/package/
done
echo "Package contents:"
ls -la build/package/
else
echo "=== Building standard Joomla extension ==="
rsync -a \
--exclude='sftp-config*' \
--exclude='.ftpignore' \
--exclude='*.ppk' \
--exclude='*.pem' \
--exclude='*.key' \
--exclude='.env*' \
--exclude='*.local' \
--exclude='.build-trigger' \
"${SOURCE_DIR}/" build/package/
fi
- name: Create ZIP
id: zip
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
- name: Create 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)
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
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: Build package and upload
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- 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)
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml skipping"
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
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
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}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
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
# Commit and push
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]"
@@ -380,13 +191,11 @@ jobs:
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}"
echo "Syncing updates.xml -> ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
@@ -400,29 +209,25 @@ jobs:
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.GA_TOKEN }}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
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
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
File diff suppressed because it is too large Load Diff
+150 -302
View File
@@ -4,20 +4,18 @@
#
# 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
# INGROUP: moko-platform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml
# VERSION: 05.00.00
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
#
# 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/**
# Thin wrapper around moko-platform CLI tools.
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
#
# Joomla filters by user's "Minimum Stability" setting.
# Joomla filters update entries by the user's "Minimum Stability" setting.
name: "Joomla: Update Server"
name: "Update Server"
on:
push:
@@ -66,55 +64,60 @@ permissions:
jobs:
update-xml:
name: Update updates.xml
name: Update Server
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
uses: actions/checkout@v4
with:
token: ${{ secrets.GA_TOKEN }}
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Setup MokoStandards tools
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_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
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform
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
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || true
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
- name: Generate updates.xml entry
id: update
- name: Detect platform
id: platform
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve stability and bump version
id: meta
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)
# Configure git for bot pushes
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
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Determine stability from branch or input
# Auto-bump patch version
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Strip any existing suffix before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
@@ -123,277 +126,122 @@ jobs:
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
else
STABILITY="development"
else
STABILITY="stable"
fi
# Version suffix per stability stream
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
*) SUFFIX=""; TAG="stable" ;;
esac
# Propagate version with stability suffix to all manifest files
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Re-read version (now includes suffix from version_set_platform)
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "display_version=${VERSION}" >> "$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
# Commit version bump if changed
git add -A
git diff --cached --quiet || {
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
# -- Sync updates.xml to main (for non-main branches) ----------------------
- name: Create release and upload package
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Create or update Gitea release
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
# Build package and upload
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push updates.xml
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push
}
- name: Sync updates.xml to main
if: github.ref_name != 'main'
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GA_TOKEN="${{ secrets.GA_TOKEN }}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_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
python3 -c "
import base64, json, urllib.request, sys
with open('updates.xml', 'rb') as f:
content = base64.b64encode(f.read()).decode()
payload = json.dumps({
'content': content,
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
'branch': 'main'
}).encode()
req = urllib.request.Request(
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GITEA_TOKEN}',
'Content-Type': 'application/json'
})
try:
urllib.request.urlopen(req)
print('updates.xml synced to main')
except Exception as e:
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
"
fi
- name: SFTP deploy to dev server
@@ -407,12 +255,11 @@ jobs:
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# -- Permission check: admin or maintain role required --------
# 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 }}" \
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_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
@@ -442,11 +289,11 @@ jobs:
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
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
php ${MOKO_CLI}/../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
@@ -454,11 +301,12 @@ jobs:
- name: Summary
if: always()
run: |
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
DISPLAY="${{ steps.meta.outputs.display_version }}"
echo "## 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
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
+6 -51
View File
@@ -5,58 +5,13 @@ 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)
+2
View File
@@ -56,6 +56,8 @@ make clean # Clean build artifacts
## Rules
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
- **Attribution**: use `Authored-by: Moko Consulting` in commits
- **Branch strategy**: develop on `dev/`, merge to `main` for release
+161 -245
View File
@@ -1,245 +1,161 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE).
# FILE INFORMATION
DEFGROUP: MokoStandards-Template-Joomla-Plugin
INGROUP: MokoStandards-Template-Joomla-Plugin.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/
VERSION: 01.00.00
PATH: ./CONTRIBUTING.md
BRIEF: How to contribute; commit, PR, testing and security policies
NOTE: Template repository - customize for your project
-->
# Contributing
Thank you for your interest in contributing. This document defines the baseline expectations, workflows, and quality gates for any change entering this repository.
The objective is to keep contributions predictable, reviewable, and compliant with MokoStandards while enabling a sustainable delivery pipeline.
## Governance and scope
This CONTRIBUTING file operates alongside the following governance assets:
* `README.md` for project overview and onboarding
* `LICENSE` for legal terms and reuse constraints
* `CODE_OF_CONDUCT.md` for behavioral expectations
In case of conflict, legal terms in `LICENSE` take precedence, followed by this document.
## Alignment with MokoStandards
All Moko Consulting projects are expected to comply with the shared standards defined in the `MokoStandards` repository.
* Source of truth: [https://github.com/mokoconsulting-tech/MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards)
* Areas covered: headers, licensing, coding style, documentation layout, and CI expectations
Per project policy, this file should reference standards rather than redefining them. Any deviation must be explicitly documented.
## Ways to contribute
Contributions are welcome across multiple workstreams:
* Bug reports and defect reproduction scenarios
* Feature requests aligned with the project roadmap
* Code changes, including refactors and technical debt reduction
* Documentation improvements and clarifications
* Test coverage (unit, integration, and regression scenarios)
Before investing significant effort, contributors are encouraged to review the open issues and roadmap documents to avoid misalignment.
## Communication channels
Typical communication paths include:
* GitHub Issues for bug reports, feature requests, and questions
* GitHub Discussions for design conversations, Q&A, and community engagement
* Pull Requests for change proposals
## Issue workflow
Use GitHub Issues as the system of record for all work.
When opening an issue:
1. Search existing issues to avoid duplication.
2. Select the appropriate issue template if available.
3. Provide a concise, action oriented title.
4. Supply a clear description including:
* Expected behavior
* Actual behavior
* Minimal steps to reproduce
* Environment details (version, platform, configuration)
5. Attach logs, screenshots, or configuration snippets as needed, after removing sensitive data.
Maintainers will triage issues based on impact, risk, and alignment with the roadmap. Not all requests will be accepted, but all will be reviewed in good faith.
## Pull request workflow
Pull Requests (PRs) are the primary integration path for changes.
Standard workflow:
1. Open or reference an issue describing the problem or enhancement.
2. Fork the repository or create a feature branch from the canonical default branch (commonly `main` or `master`).
3. Implement changes aligned with the coding standards and file header requirements.
4. Add or update tests to validate the behavior.
5. Update documentation where behavior, configuration, or interfaces change.
6. Run the full test and linting suite locally before opening the PR.
7. Open a PR with:
* A precise, descriptive title
* A summary of changes
* Explicit linkage to the corresponding issue
* Notes on testing performed and any known limitations
PRs must pass automated checks before they will be considered for review. The maintainer team reserves the right to request revisions, split changes, or defer work that does not align with the current release plan.
## Merge strategy
This repository uses **squash merge** as the only permitted merge method for pull requests to the main branch. This ensures a clean, linear git history where each commit represents a complete, reviewed change.
Key implications for contributors:
* **PR Title is Important**: The PR title becomes the commit message subject. Make it clear and descriptive.
* **PR Description is Important**: The PR description becomes the commit message body. Include rationale and summary of changes.
* **Automatic Cleanup**: Branches are automatically deleted after merge.
* **No Merge Commits**: Regular merge commits and rebase merges are disabled.
## Branching and versioning
Unless specified otherwise:
* Default development branch: `main`
* Feature work: short lived feature branches named using a predictable convention, for example `feature/<short-description>` or `fix/<short-description>`
* Releases: tagged using semantic versioning (`MAJOR.MINOR.PATCH`)
## Coding standards and file headers
This project adheres to the coding conventions and header rules defined in MokoStandards. At a minimum:
* All source and configuration files must include the standard SPDX compatible header where applicable.
* Language specific style guides (for example PHP, JavaScript, Python) must be followed.
* Follow Joomla coding standards for PHP code.
## Commit message guidelines
Commit messages are part of the project audit trail. They should be structured and descriptive.
Recommended format:
* Short subject line in the imperative mood, for example `Add`, `Fix`, `Refactor`
* Optional body that explains the rationale, constraints, and side effects
* Reference to related issues using `Fixes #<id>` or `Refs #<id>` as appropriate
Avoid bundling unrelated changes into a single commit. Small, logically grouped commits improve traceability and rollback options.
## Testing expectations
Before opening a PR, contributors are expected to:
* Run all available automated tests (unit, integration, and other configured checks)
* Ensure linting and static analysis pass without new violations
* Test changes with a local Joomla installation when applicable
For new features or non trivial fixes, please include tests that:
* Reproduce the defect, or
* Demonstrate the new behavior
If tests are not included, the PR should clearly state why (for example, infrastructure limitations, complex external dependencies, or pure documentation changes).
## Documentation contributions
Documentation is a first class asset in this ecosystem.
When contributing documentation:
* Align with the established docs hierarchy (for example `docs/`)
* Apply the shared template structure for new documents
* Include appropriate navigation, metadata, and revision history sections when required by the documentation standards
Minor corrections such as typo fixes are welcome, but larger structural changes should be coordinated via an issue or design note first.
## Security and responsible disclosure
Security sensitive issues must not be reported in public issues.
Use the security contact channel defined in `SECURITY.md` to share details.
Provide enough information for maintainers to reproduce and understand the impact. The team will coordinate fixes and disclosure timelines as appropriate.
## License and contributor agreement
Unless stated otherwise, contributions to this repository are accepted under the same license as the project, GPL 3.0 or later.
By submitting a contribution, you confirm that:
* You have the right to contribute the code or content.
* You agree that your contribution will be licensed under the project license.
* You will not submit content that infringes third party rights.
## Escalation and decision making
If you disagree with a review decision or prioritization decision:
1. Escalate by commenting on the issue or PR with your reasoning.
2. Request a second review from a different maintainer.
3. Contact the project maintainers directly via the channels listed in README.md.
Final decisions rest with the project maintainers, but all concerns will be considered fairly.
---
## Contact
For questions or clarifications about this contributing guide:
* Open a GitHub Issue
* Contact: hello@mokoconsulting.tech
Thank you for contributing to this project and supporting the MokoStandards ecosystem!
## Infrastructure Standards
All repositories in the MokoConsulting org follow these conventions:
### Release Tags
Every repo maintains 5 standard release channel tags:
- `development` - Active development builds
- `alpha` - Early internal testing
- `beta` - Broader testing / client UAT
- `release-candidate` - Final QA before production
- `stable` - Production release
### Branch Protection
- `main` is protected; only `jmiller` can push directly
- All other contributors must use pull requests
- PRs are automatically reviewed by Claude Code
### CI/CD
- Gitea Actions runs all CI workflows
- GitHub Actions are disabled on mirrored repos
- Workflows live in both `.github/workflows/` and `.gitea/workflows/`
### Update Servers (Joomla)
In manifest `<updateservers>`, Gitea must be priority 1, GitHub priority 2.
### Secrets
All repos have `GA_TOKEN` and `GH_TOKEN` as Actions secrets for API access.
# Contributing to Moko Consulting Projects
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
## Branching Workflow
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
```
### Step by step
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
2. **Work and commit** on your feature branch. Push to origin.
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
- This automatically renames the source branch to `rc` (release candidate)
- An RC pre-release is built and uploaded
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
- When the draft PR is created, the branch is renamed to `rc`
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
7. **Merging to main** triggers the stable release pipeline:
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
- Stability suffix stripped (clean version)
- Gitea release created with ZIP/tar.gz packages
- `updates.xml` updated (Joomla extensions)
- `dev` branch recreated from `main`
### Branch summary
| Branch | Purpose | Created by |
|--------|---------|-----------|
| `feature/*` | New features and fixes | Developer |
| `dev` | Integration branch | Auto-recreated after release |
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
| `rc` | Release candidate | Auto-renamed on draft PR to main |
| `main` | Stable releases | Protected, merge only |
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
### Protected branches
| Branch | Direct push | Merge via |
|--------|------------|-----------|
| `main` | Blocked (CI bot whitelisted) | PR merge only |
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
| `feature/*` | Open | N/A (source branch) |
## Version Policy
### Format
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
- **XX** — Major version (breaking changes)
- **YY** — Minor version (new features, bumped on release to main)
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
### Stability suffixes
Each branch appends a suffix to indicate stability:
| Branch | Suffix | Example |
|--------|--------|---------|
| `main` | (none) | `02.09.00` |
| `dev` | `-dev` | `02.09.01-dev` |
| `feature/*` | `-dev` | `02.09.01-dev` |
| `alpha` | `-alpha` | `02.09.01-alpha` |
| `beta` | `-beta` | `02.09.01-beta` |
| `rc` | `-rc` | `02.09.01-rc` |
### Auto version bump
On every push to `dev`, `feature/*`, or `patch/*`:
1. Patch version incremented
2. Stability suffix `-dev` applied
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
4. Commit created with `[skip ci]` to avoid loops
### Release version flow
Version bumps happen at specific release events:
| Event | Bump | Example |
|-------|------|---------|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
### Release stream copies
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
### Version files
The version tools update all files containing version stamps:
- `.mokogitea/manifest.xml` (canonical source)
- Joomla XML manifests (`<version>` tag)
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
- `package.json`, `pyproject.toml`
- Any text file with a `VERSION: XX.YY.ZZ` label
Files synced from other repos (with a `# REPO:` header) are not touched.
## Code Standards
- **PHP**: PSR-12, tabs for indentation
- **Copyright**: all files must include the Moko Consulting copyright header
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
## Commit Messages
Use conventional commit format:
```
type(scope): short description
Optional body with context.
Authored-by: Moko Consulting
```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
Special flags in commit messages:
- `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only
## Reporting Issues
Use the repository's issue tracker with the appropriate template.
---
*Moko Consulting <hello@mokoconsulting.tech>*
+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
@@ -1,176 +0,0 @@
/**
* 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,66 +27,5 @@ 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,52 +45,6 @@ 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,6 +6,3 @@
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`;
+6 -111
View File
@@ -13,7 +13,6 @@ 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.
@@ -37,28 +36,20 @@ 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 bool|null $verifySSL Override SSL verification.
* @param string|null $baseUrl Override base URL.
* @param string|null $apiKey Override API key.
*
* @since 1.0.0
*/
public function __construct(?string $baseUrl = null, ?string $apiKey = null, ?bool $verifySSL = null)
public function __construct(?string $baseUrl = null, ?string $apiKey = null)
{
$params = ComponentHelper::getParams('com_mokodolijoomshop');
$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);
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
}
/**
@@ -106,20 +97,6 @@ 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.
*
@@ -134,82 +111,6 @@ 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.
*
@@ -246,13 +147,7 @@ class DolibarrClient
try
{
$options = new Registry();
$options->set('transport.curl', [
CURLOPT_SSL_VERIFYPEER => $this->verifySSL,
CURLOPT_SSL_VERIFYHOST => $this->verifySSL ? 2 : 0,
]);
$http = HttpFactory::getHttp($options);
$http = HttpFactory::getHttp();
$jsonBody = !empty($body) ? json_encode($body) : null;
switch (strtoupper($method))
-158
View File
@@ -1,158 +0,0 @@
<?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
@@ -1,168 +0,0 @@
<?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
@@ -1,143 +0,0 @@
<?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);
}
}
@@ -1,259 +0,0 @@
<?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
@@ -1,244 +0,0 @@
<?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
@@ -1,239 +0,0 @@
<?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
@@ -1,224 +0,0 @@
<?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
@@ -1,243 +0,0 @@
<?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
@@ -1,65 +0,0 @@
<?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
@@ -1,60 +0,0 @@
<?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
@@ -1,53 +0,0 @@
<?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;
}
}
+1 -59
View File
@@ -10,11 +10,9 @@ 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.
@@ -29,48 +27,6 @@ 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.
*
@@ -83,21 +39,7 @@ class HtmlView extends BaseHtmlView
public function display($tpl = null): void
{
$client = new DolibarrClient();
$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();
}
$this->connectionOk = $client->testConnection();
ToolbarHelper::title('DoliJoom Shop: Dashboard');
ToolbarHelper::preferences('com_mokodolijoomshop');
-56
View File
@@ -1,56 +0,0 @@
<?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);
}
}
+9 -137
View File
@@ -9,177 +9,49 @@
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">
<!-- Connection Status -->
<div class="row mb-4">
<div class="row">
<div class="col-lg-6">
<div class="card">
<div class="card mb-3">
<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 mb-2">
<div class="alert alert-success">
<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 htmlspecialchars($status['error'] ?: Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED')); ?>
<?php echo 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">
<div class="card mb-3">
<div class="card-header">
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUICK_ACTIONS'); ?></h3>
<h3 class="card-title">Quick Actions</h3>
</div>
<div class="card-body">
<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>
<a href="index.php?option=com_mokodolijoomshop&view=products" class="btn btn-primary mb-2 d-block">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
</a>
<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>
<a href="index.php?option=com_mokodolijoomshop&view=orders" class="btn btn-outline-primary mb-2 d-block">
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?>
</a>
<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>
<a href="index.php?option=com_mokodolijoomshop&view=customers" class="btn btn-outline-primary mb-2 d-block">
<?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
@@ -1,93 +0,0 @@
<?php
/**
* @package MokoDoliJoomShop
* @subpackage com_mokodolijoomshop
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Orders\HtmlView $this */
$currency = htmlspecialchars($this->currency);
?>
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" method="get" id="adminForm" name="adminForm">
<input type="hidden" name="option" value="com_mokodolijoomshop" />
<input type="hidden" name="view" value="orders" />
<!-- Filters -->
<div class="row mb-3">
<div class="col-md-3">
<input type="text" name="filter_search" class="form-control" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_SEARCH'); ?>"
value="<?php echo htmlspecialchars(\Joomla\CMS\Factory::getApplication()->input->getString('filter_search', '')); ?>" />
</div>
<div class="col-md-2">
<select name="filter_status" class="form-select">
<option value=""><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="validated">Validated</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="col-md-2">
<input type="date" name="filter_date_from" class="form-control" placeholder="From" />
</div>
<div class="col-md-2">
<input type="date" name="filter_date_to" class="form-control" placeholder="To" />
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary">
<span class="icon-search" aria-hidden="true"></span>
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
</button>
</div>
</div>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
<?php else : ?>
<table class="table table-striped" id="orderList">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_HT'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC'); ?></th>
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $order) : ?>
<?php
$statusClass = match ($order['status'] ?? '') {
'confirmed', 'validated' => 'bg-success',
'shipped' => 'bg-info',
'delivered' => 'bg-primary',
'cancelled' => 'bg-danger',
default => 'bg-secondary',
};
?>
<tr>
<td><?php echo htmlspecialchars($order['created']); ?></td>
<td><?php echo htmlspecialchars($order['customer_name'] ?? ''); ?></td>
<td><?php echo htmlspecialchars($order['order_ref']); ?></td>
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
<td class="text-end"><?php echo number_format((float) $order['total_ht'], 2); ?> <?php echo $currency; ?></td>
<td class="text-end"><?php echo number_format((float) $order['total_ttc'], 2); ?> <?php echo $currency; ?></td>
<td><span class="badge <?php echo $statusClass; ?>"><?php echo htmlspecialchars($order['status']); ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</form>
-73
View File
@@ -37,14 +37,8 @@
</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>
@@ -133,77 +127,10 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="tax_display"
type="list"
label="COM_MOKODOLIJOOMSHOP_FIELD_TAX_DISPLAY"
default="ttc"
>
<option value="ttc">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_TTC</option>
<option value="ht">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_HT</option>
<option value="both">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_BOTH</option>
</field>
<field
name="low_stock_threshold"
type="number"
label="COM_MOKODOLIJOOMSHOP_FIELD_LOW_STOCK_THRESHOLD"
default="5"
min="0"
max="999"
/>
<field
name="allow_backorder"
type="radio"
label="COM_MOKODOLIJOOMSHOP_FIELD_ALLOW_BACKORDER"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="performance" label="COM_MOKODOLIJOOMSHOP_FIELDSET_PERFORMANCE">
<field
name="cache_enabled"
type="radio"
label="COM_MOKODOLIJOOMSHOP_FIELD_CACHE_ENABLED"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="cache_ttl"
type="number"
label="COM_MOKODOLIJOOMSHOP_FIELD_CACHE_TTL"
default="900"
min="60"
max="86400"
/>
</fieldset>
<fieldset name="webhooks" label="COM_MOKODOLIJOOMSHOP_FIELDSET_WEBHOOKS">
<field
name="webhook_secret"
type="password"
label="COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET"
description="COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET_DESC"
/>
</fieldset>
</fields>
</config>
<access section="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="mokodolijoomshop.products.manage" title="COM_MOKODOLIJOOMSHOP_ACL_PRODUCTS_MANAGE" />
<action name="mokodolijoomshop.orders.view" title="COM_MOKODOLIJOOMSHOP_ACL_ORDERS_VIEW" />
<action name="mokodolijoomshop.customers.manage" title="COM_MOKODOLIJOOMSHOP_ACL_CUSTOMERS_MANAGE" />
<action name="mokodolijoomshop.settings.manage" title="COM_MOKODOLIJOOMSHOP_ACL_SETTINGS_MANAGE" />
</access>
<updateservers>
<server type="extension" name="MokoDoliJoomShop Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoDoliJoomShop/raw/branch/main/updates.xml</server>
</updateservers>
@@ -10,106 +10,12 @@ 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"
+1 -2
View File
@@ -8,5 +8,4 @@
defined('_JEXEC') or die;
// 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.
// Site service provider — component registration handled by admin provider
-104
View File
@@ -1,104 +0,0 @@
<?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));
}
}
@@ -1,102 +0,0 @@
<?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));
}
}
@@ -1,121 +0,0 @@
<?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();
}
}
@@ -1,111 +0,0 @@
<?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
@@ -1,103 +0,0 @@
<?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
@@ -1,116 +0,0 @@
<?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
@@ -1,159 +0,0 @@
<?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
@@ -1,171 +0,0 @@
<?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
@@ -1,176 +0,0 @@
<?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
@@ -1,385 +0,0 @@
<?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
@@ -1,190 +0,0 @@
<?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
@@ -1,137 +0,0 @@
<?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
@@ -1,194 +0,0 @@
<?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
@@ -1,179 +0,0 @@
<?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
@@ -1,137 +0,0 @@
<?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
@@ -1,172 +0,0 @@
<?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
@@ -1,187 +0,0 @@
<?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
@@ -1,61 +0,0 @@
<?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
@@ -1,99 +0,0 @@
<?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
@@ -1,94 +0,0 @@
<?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
@@ -1,82 +0,0 @@
<?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
@@ -1,85 +0,0 @@
<?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
@@ -1,84 +0,0 @@
<?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
@@ -1,106 +0,0 @@
<?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
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKODOLIJOOMSHOP_CART">
<message>COM_MOKODOLIJOOMSHOP_CART_DESC</message>
</layout>
</metadata>
-159
View File
@@ -1,159 +0,0 @@
<?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
@@ -1,17 +0,0 @@
<?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
@@ -1,47 +0,0 @@
<?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
@@ -1,129 +0,0 @@
<?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
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<layout title="COM_MOKODOLIJOOMSHOP_CHECKOUT">
<message>COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC</message>
</layout>
</metadata>
-126
View File
@@ -1,126 +0,0 @@
<?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
@@ -1,6 +0,0 @@
<?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
@@ -1,159 +0,0 @@
<?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
@@ -1,17 +0,0 @@
<?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
@@ -1,103 +0,0 @@
<?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
@@ -1,31 +0,0 @@
<?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>