generated from MokoConsulting/Template-Joomla
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d257444420 | |||
| 2a0142c77e | |||
| 08cfe50ce2 | |||
| 58439eb320 | |||
| 8e62b77d04 | |||
| fbda737b8c | |||
| 10ccbf45a7 | |||
| 4fd2959812 | |||
| 2cd0317a22 | |||
| 57d989cce6 | |||
| 1fcef28b97 | |||
| 05e0428bc7 | |||
| 915663694c | |||
| f69fd34e0b | |||
| 8f6ff4be16 | |||
| 75ff48005a | |||
| 5ab47f9dd3 | |||
| 6ff1d5c478 | |||
| eea11a2da6 | |||
| 1a55c7f9ab | |||
| 7ec525803a | |||
| f5ce2498d7 | |||
| 235d8aaec3 | |||
| bd7fbb55e5 | |||
| 8041b1def2 | |||
| 42bdd4de33 | |||
| 1b04615ec2 | |||
| 2d5195603f | |||
| db51aec0c4 | |||
| 71b649849d | |||
| d8b467dd18 | |||
| a7d5d801fd | |||
| 1180bd9047 | |||
| 362480a7ed | |||
| 66f7ebd369 | |||
| e11f177215 | |||
| df8b2a90d9 | |||
| 7d10b89865 | |||
| 4921d8d7c4 | |||
| ce729cc072 | |||
| 2836360f73 | |||
| afb711bc1d | |||
| a305d423c3 | |||
| 125e505492 | |||
| f9a3fe3639 | |||
| b34c323d81 | |||
| 80c94bbc27 | |||
| daa6d91fd5 | |||
| bb3e0636ef | |||
| 2c4227656e | |||
| d154d2d309 | |||
| a15ee9c8bd | |||
| 3ff1a3464b | |||
| fccac1a510 | |||
| 7e175d8af2 | |||
| 8d87f3920a | |||
| ea0fe519b2 | |||
| 9b0de50cae | |||
| 8b496dc26b | |||
| b32aa8b573 | |||
| dcc652157c | |||
| df59df6ea9 | |||
| 9e30eb787b | |||
| 32539543df | |||
| b4b8b026e7 | |||
| c1c0aef952 | |||
| 652d27fa40 | |||
| bd2340da9b | |||
| 0ba983086a | |||
| a44e51ce5c | |||
| 300f6cad1a | |||
| 7396dc3b86 | |||
| 0dbf8c6891 | |||
| 4eaa742c8c | |||
| 41a1efdd2c |
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Maintenance
|
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
|
||||||
# PATH: /templates/workflows/cascade-dev.yml.template
|
|
||||||
# VERSION: 02.00.00
|
|
||||||
# BRIEF: Forward-merge main → all open branches after every push to main
|
|
||||||
#
|
|
||||||
# +========================================================================+
|
|
||||||
# | CASCADE MAIN → ALL BRANCHES |
|
|
||||||
# +========================================================================+
|
|
||||||
# | |
|
|
||||||
# | Triggers on every push to main (PR merges, bot commits, etc.) |
|
|
||||||
# | |
|
|
||||||
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
|
|
||||||
# | 2. For each: create PR (main → branch), auto-merge if clean |
|
|
||||||
# | 3. On conflict: leave PR open for manual resolution |
|
|
||||||
# | |
|
|
||||||
# +========================================================================+
|
|
||||||
|
|
||||||
name: "Universal: Cascade Main → Dev"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cascade:
|
|
||||||
name: Cascade main → branches
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: >-
|
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
|
||||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Discover target branches
|
|
||||||
id: branches
|
|
||||||
env:
|
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
|
|
||||||
# Fetch all branches (paginated)
|
|
||||||
PAGE=1
|
|
||||||
ALL_BRANCHES=""
|
|
||||||
while true; do
|
|
||||||
BATCH=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API}/branches?page=${PAGE}&limit=50" \
|
|
||||||
| jq -r '.[].name // empty')
|
|
||||||
[ -z "$BATCH" ] && break
|
|
||||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
|
||||||
PAGE=$((PAGE + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
|
||||||
TARGETS=""
|
|
||||||
for BRANCH in $ALL_BRANCHES; do
|
|
||||||
case "$BRANCH" in
|
|
||||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
|
||||||
TARGETS="$TARGETS $BRANCH"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
|
||||||
|
|
||||||
if [ -z "$TARGETS" ]; then
|
|
||||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ℹ️ No cascade target branches found"
|
|
||||||
else
|
|
||||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
|
||||||
COUNT=$(echo "$TARGETS" | wc -w)
|
|
||||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Cascade to all target branches
|
|
||||||
if: steps.branches.outputs.targets != ''
|
|
||||||
env:
|
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
|
||||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
|
||||||
|
|
||||||
SUCCESS=0
|
|
||||||
CONFLICTS=0
|
|
||||||
SKIPPED=0
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
for BRANCH in $TARGETS; do
|
|
||||||
echo ""
|
|
||||||
echo "═══ main → ${BRANCH} ═══"
|
|
||||||
|
|
||||||
# Check if branch is already up to date
|
|
||||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
|
||||||
RESPONSE=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
|
||||||
|
|
||||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
|
||||||
|
|
||||||
if [ "$AHEAD" -eq 0 ]; then
|
|
||||||
echo " ✅ Already up to date"
|
|
||||||
SKIPPED=$((SKIPPED + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
|
||||||
|
|
||||||
# Check for existing cascade PR
|
|
||||||
EXISTING=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
|
||||||
|
|
||||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
|
||||||
PR_NUMBER=""
|
|
||||||
|
|
||||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
|
||||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
|
||||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
|
||||||
else
|
|
||||||
# Create cascade PR
|
|
||||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
|
||||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
|
||||||
\"head\": \"main\",
|
|
||||||
\"base\": \"${BRANCH}\"
|
|
||||||
}" \
|
|
||||||
"${API}/pulls")
|
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
|
||||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
|
||||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
|
||||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
|
||||||
FAILED=$((FAILED + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " ✅ Created PR #${PR_NUMBER}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try auto-merge
|
|
||||||
PR_DATA=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API}/pulls/${PR_NUMBER}")
|
|
||||||
|
|
||||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
|
||||||
|
|
||||||
if [ "$MERGEABLE" != "true" ]; then
|
|
||||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
|
||||||
CONFLICTS=$((CONFLICTS + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"Do\": \"merge\",
|
|
||||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
|
||||||
\"delete_branch_after_merge\": false
|
|
||||||
}" \
|
|
||||||
"${API}/pulls/${PR_NUMBER}/merge")
|
|
||||||
|
|
||||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
|
||||||
|
|
||||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
|
||||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
|
||||||
SUCCESS=$((SUCCESS + 1))
|
|
||||||
else
|
|
||||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
|
||||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
|
||||||
CONFLICTS=$((CONFLICTS + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo ""
|
|
||||||
echo "════════════════════════════════════════"
|
|
||||||
echo " ✅ Merged: ${SUCCESS}"
|
|
||||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
|
||||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
|
||||||
echo " ❌ Failed: ${FAILED}"
|
|
||||||
echo "════════════════════════════════════════"
|
|
||||||
|
|
||||||
if [ "$FAILED" -gt 0 ]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.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
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Release
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
|
||||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
|
||||||
# VERSION: 05.00.00
|
|
||||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
|
||||||
|
|
||||||
name: "Universal: Pre-Release"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
stability:
|
|
||||||
description: 'Pre-release channel'
|
|
||||||
required: true
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- development
|
|
||||||
- alpha
|
|
||||||
- beta
|
|
||||||
- release-candidate
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
|
||||||
runs-on: release
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
run: |
|
|
||||||
if ! command -v php &> /dev/null; then
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Detect platform
|
|
||||||
id: platform
|
|
||||||
run: |
|
|
||||||
# Parse .manifest.xml via manifest_read.php — outputs all fields to GITHUB_OUTPUT
|
|
||||||
php /tmp/mokostandards-api/cli/manifest_read.php --path . --github-output 2>/dev/null || true
|
|
||||||
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
|
|
||||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
|
||||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
|
||||||
# entry-point from manifest, find as fallback
|
|
||||||
MOD_FILE=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field entry-point 2>/dev/null)
|
|
||||||
[ -z "$MOD_FILE" ] && MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
|
||||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
|
||||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Resolve metadata
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
STABILITY="${{ inputs.stability }}"
|
|
||||||
|
|
||||||
case "$STABILITY" in
|
|
||||||
development) SUFFIX="-dev"; TAG="development" ;;
|
|
||||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
|
||||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
|
||||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Read and bump patch version (with rollover)
|
|
||||||
CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
|
||||||
[ -z "$CURRENT" ] && CURRENT="00.00.00"
|
|
||||||
|
|
||||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
|
||||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
|
||||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
|
||||||
|
|
||||||
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
|
|
||||||
NEW_PATCH=$((10#$PATCH + 1))
|
|
||||||
NEW_MINOR=$((10#$MINOR))
|
|
||||||
NEW_MAJOR=$((10#$MAJOR))
|
|
||||||
|
|
||||||
if [ $NEW_PATCH -gt 99 ]; then
|
|
||||||
NEW_PATCH=0
|
|
||||||
NEW_MINOR=$((NEW_MINOR + 1))
|
|
||||||
fi
|
|
||||||
if [ $NEW_MINOR -gt 99 ]; then
|
|
||||||
NEW_MINOR=0
|
|
||||||
NEW_MAJOR=$((NEW_MAJOR + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
|
|
||||||
TODAY=$(date +%Y-%m-%d)
|
|
||||||
|
|
||||||
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
|
|
||||||
|
|
||||||
# Update README.md
|
|
||||||
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
|
|
||||||
|
|
||||||
# Update platform-specific manifest
|
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
|
||||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
|
||||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
|
||||||
case "$PLATFORM" in
|
|
||||||
joomla)
|
|
||||||
if [ -n "$MANIFEST" ]; then
|
|
||||||
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
|
||||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
dolibarr)
|
|
||||||
if [ -n "$MOD_FILE" ]; then
|
|
||||||
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*) ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Commit version bump
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
|
||||||
git add -A
|
|
||||||
git diff --cached --quiet || {
|
|
||||||
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
|
|
||||||
git push origin HEAD 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Auto-detect element (platform-aware)
|
|
||||||
case "$PLATFORM" in
|
|
||||||
joomla)
|
|
||||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
|
||||||
EXT_ELEMENT=""
|
|
||||||
if [ -n "$MANIFEST" ]; then
|
|
||||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
|
||||||
if [ -z "$EXT_ELEMENT" ]; then
|
|
||||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "$EXT_ELEMENT" in
|
|
||||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
dolibarr)
|
|
||||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
|
||||||
if [ -n "$MOD_FILE" ]; then
|
|
||||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
|
||||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
|
||||||
else
|
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
|
||||||
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
|
||||||
|
|
||||||
- name: Build package
|
|
||||||
run: |
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
if [ ! -d "$SOURCE_DIR" ]; then
|
|
||||||
echo "::error::No src/ or htdocs/ directory"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p build/package
|
|
||||||
rsync -a \
|
|
||||||
--exclude='sftp-config*' \
|
|
||||||
--exclude='.ftpignore' \
|
|
||||||
--exclude='*.ppk' \
|
|
||||||
--exclude='*.pem' \
|
|
||||||
--exclude='*.key' \
|
|
||||||
--exclude='.env*' \
|
|
||||||
--exclude='*.local' \
|
|
||||||
--exclude='.build-trigger' \
|
|
||||||
"${SOURCE_DIR}/" build/package/
|
|
||||||
|
|
||||||
- name: Create ZIP
|
|
||||||
id: zip
|
|
||||||
run: |
|
|
||||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
|
||||||
cd build/package
|
|
||||||
zip -r "../${ZIP_NAME}" .
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
|
||||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
|
||||||
|
|
||||||
- name: Create or replace Gitea release
|
|
||||||
id: release
|
|
||||||
run: |
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
|
||||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
|
||||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
|
||||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
|
||||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
|
||||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
BRANCH=$(git branch --show-current)
|
|
||||||
|
|
||||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
|
||||||
**Channel:** ${STABILITY}
|
|
||||||
**SHA-256:** \`${SHA256}\`"
|
|
||||||
|
|
||||||
# Delete existing release
|
|
||||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
|
||||||
if [ -n "$EXISTING_ID" ]; then
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create release
|
|
||||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/releases" \
|
|
||||||
-d "$(jq -n \
|
|
||||||
--arg tag "$TAG" \
|
|
||||||
--arg target "$BRANCH" \
|
|
||||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
|
||||||
--arg body "$BODY" \
|
|
||||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
|
||||||
)" | jq -r '.id')
|
|
||||||
|
|
||||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Upload ZIP
|
|
||||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
|
||||||
--data-binary "@build/${ZIP_NAME}"
|
|
||||||
|
|
||||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
|
||||||
|
|
||||||
- name: Update updates.xml
|
|
||||||
if: steps.platform.outputs.platform == 'joomla'
|
|
||||||
run: |
|
|
||||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
|
||||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
|
||||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
DATE=$(date +%Y-%m-%d)
|
|
||||||
|
|
||||||
if [ ! -f "updates.xml" ]; then
|
|
||||||
echo "No updates.xml — skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
|
|
||||||
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
|
|
||||||
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
|
|
||||||
python3 << 'PYEOF'
|
|
||||||
import re, os
|
|
||||||
|
|
||||||
stability = os.environ["PY_STABILITY"]
|
|
||||||
version = os.environ["PY_VERSION"]
|
|
||||||
sha256 = os.environ["PY_SHA256"]
|
|
||||||
zip_name = os.environ["PY_ZIP_NAME"]
|
|
||||||
tag = os.environ["PY_TAG"]
|
|
||||||
date = os.environ["PY_DATE"]
|
|
||||||
gitea_org = os.environ["PY_GITEA_ORG"]
|
|
||||||
gitea_repo = os.environ["PY_GITEA_REPO"]
|
|
||||||
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
|
|
||||||
|
|
||||||
with open("updates.xml", "r") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Map stability to XML tag name
|
|
||||||
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
|
|
||||||
xml_tag = tag_map.get(stability, stability)
|
|
||||||
|
|
||||||
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
|
|
||||||
match = re.search(pattern, content, re.DOTALL)
|
|
||||||
if match:
|
|
||||||
block = match.group(1)
|
|
||||||
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
|
|
||||||
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
|
|
||||||
if "<sha256>" in updated:
|
|
||||||
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
|
|
||||||
else:
|
|
||||||
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
|
|
||||||
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
|
|
||||||
content = content.replace(block, updated)
|
|
||||||
print(f"Updated {xml_tag} channel: version={version}")
|
|
||||||
else:
|
|
||||||
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
|
|
||||||
|
|
||||||
with open("updates.xml", "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
# Commit and push to current branch
|
|
||||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
git add updates.xml
|
|
||||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
|
||||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: "Sync updates.xml to all branches"
|
|
||||||
if: steps.platform.outputs.platform == 'joomla'
|
|
||||||
run: |
|
|
||||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
|
|
||||||
# Sync updates.xml to main and dev (whichever isn't current)
|
|
||||||
for BRANCH in main dev; do
|
|
||||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
|
||||||
|
|
||||||
echo "Syncing updates.xml → ${BRANCH}"
|
|
||||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
|
||||||
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
|
|
||||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
|
||||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
|
||||||
git add updates.xml
|
|
||||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
|
||||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
|
||||||
fi
|
|
||||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: "Delete lesser pre-release channels (cascade)"
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
|
||||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
|
||||||
|
|
||||||
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
|
|
||||||
case "$STABILITY" in
|
|
||||||
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
|
|
||||||
beta) TAGS_TO_DELETE="alpha development" ;;
|
|
||||||
alpha) TAGS_TO_DELETE="development" ;;
|
|
||||||
*) TAGS_TO_DELETE="" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[ -z "$TAGS_TO_DELETE" ] && exit 0
|
|
||||||
|
|
||||||
for TAG in $TAGS_TO_DELETE; do
|
|
||||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
|
||||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
|
||||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Joomla
|
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
|
||||||
# PATH: /templates/workflows/joomla/update-server.yml.template
|
|
||||||
# VERSION: 04.06.00
|
|
||||||
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
|
|
||||||
#
|
|
||||||
# Writes updates.xml with multiple <update> entries:
|
|
||||||
# - <tag>stable</tag> on push to main (from auto-release)
|
|
||||||
# - <tag>rc</tag> on push to rc/**
|
|
||||||
# - <tag>development</tag> on push to dev or dev/**
|
|
||||||
#
|
|
||||||
# Joomla filters by user's "Minimum Stability" setting.
|
|
||||||
|
|
||||||
name: "Joomla: Update Server"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'dev'
|
|
||||||
- 'dev/**'
|
|
||||||
- 'alpha/**'
|
|
||||||
- 'beta/**'
|
|
||||||
- 'rc/**'
|
|
||||||
paths:
|
|
||||||
- 'src/**'
|
|
||||||
- 'htdocs/**'
|
|
||||||
pull_request:
|
|
||||||
types: [closed]
|
|
||||||
branches:
|
|
||||||
- 'dev'
|
|
||||||
- 'dev/**'
|
|
||||||
- 'alpha/**'
|
|
||||||
- 'beta/**'
|
|
||||||
- 'rc/**'
|
|
||||||
paths:
|
|
||||||
- 'src/**'
|
|
||||||
- 'htdocs/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
stability:
|
|
||||||
description: 'Stability tag'
|
|
||||||
required: true
|
|
||||||
default: 'development'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- development
|
|
||||||
- alpha
|
|
||||||
- beta
|
|
||||||
- rc
|
|
||||||
- stable
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-xml:
|
|
||||||
name: Update updates.xml
|
|
||||||
runs-on: release
|
|
||||||
if: >-
|
|
||||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup MokoStandards tools
|
|
||||||
env:
|
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
|
||||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
|
||||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
|
|
||||||
run: |
|
|
||||||
if ! command -v composer &> /dev/null; then
|
|
||||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
git clone --depth 1 --branch main --quiet \
|
|
||||||
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
|
||||||
/tmp/mokostandards-api 2>/dev/null || true
|
|
||||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
|
||||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate updates.xml entry
|
|
||||||
id: update
|
|
||||||
run: |
|
|
||||||
BRANCH="${{ github.ref_name }}"
|
|
||||||
REPO="${{ github.repository }}"
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
|
||||||
|
|
||||||
# Auto-bump patch on all branches (dev, alpha, beta, rc)
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
|
|
||||||
if [ -n "$BUMPED" ]; then
|
|
||||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
|
|
||||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
|
|
||||||
git push 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Determine stability from branch or input
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
STABILITY="${{ inputs.stability }}"
|
|
||||||
elif [[ "$BRANCH" == rc/* ]]; then
|
|
||||||
STABILITY="rc"
|
|
||||||
elif [[ "$BRANCH" == beta/* ]]; then
|
|
||||||
STABILITY="beta"
|
|
||||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
|
||||||
STABILITY="alpha"
|
|
||||||
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
|
||||||
STABILITY="development"
|
|
||||||
else
|
|
||||||
STABILITY="stable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Parse manifest (portable — no grep -P)
|
|
||||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
|
||||||
if [ -z "$MANIFEST" ]; then
|
|
||||||
echo "No Joomla manifest found — skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract fields using sed (works on all runners)
|
|
||||||
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
|
|
||||||
# Fallbacks
|
|
||||||
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
|
|
||||||
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
|
|
||||||
|
|
||||||
# Derive element if not in manifest: try XML filename, then repo name
|
|
||||||
if [ -z "$EXT_ELEMENT" ]; then
|
|
||||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "$EXT_ELEMENT" in
|
|
||||||
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Use manifest version if README version is empty
|
|
||||||
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
|
|
||||||
|
|
||||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
|
|
||||||
|
|
||||||
CLIENT_TAG=""
|
|
||||||
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
|
||||||
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
|
||||||
|
|
||||||
FOLDER_TAG=""
|
|
||||||
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
|
||||||
|
|
||||||
PHP_TAG=""
|
|
||||||
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
|
||||||
|
|
||||||
# Version suffix for non-stable
|
|
||||||
DISPLAY_VERSION="$VERSION"
|
|
||||||
case "$STABILITY" in
|
|
||||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
|
||||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
|
||||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
|
||||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
|
||||||
|
|
||||||
# Each stability level has its own release tag
|
|
||||||
case "$STABILITY" in
|
|
||||||
development) RELEASE_TAG="development" ;;
|
|
||||||
alpha) RELEASE_TAG="alpha" ;;
|
|
||||||
beta) RELEASE_TAG="beta" ;;
|
|
||||||
rc) RELEASE_TAG="release-candidate" ;;
|
|
||||||
*) RELEASE_TAG="v${MAJOR}" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
|
|
||||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
|
||||||
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
|
|
||||||
# -- Build install packages (ZIP + tar.gz) --------------------
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
if [ -d "$SOURCE_DIR" ]; then
|
|
||||||
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
|
|
||||||
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
|
|
||||||
|
|
||||||
cd "$SOURCE_DIR"
|
|
||||||
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
|
|
||||||
cd ..
|
|
||||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
|
|
||||||
--exclude='.ftpignore' --exclude='sftp-config*' \
|
|
||||||
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
|
||||||
|
|
||||||
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
|
||||||
|
|
||||||
# Ensure release exists on Gitea
|
|
||||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
# Create release
|
|
||||||
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API_BASE}/releases" \
|
|
||||||
-d "$(python3 -c "import json; print(json.dumps({
|
|
||||||
'tag_name': '${RELEASE_TAG}',
|
|
||||||
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
|
|
||||||
'body': '${STABILITY} release',
|
|
||||||
'prerelease': True,
|
|
||||||
'target_commitish': 'main'
|
|
||||||
}))")" 2>/dev/null || true)
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$RELEASE_ID" ]; then
|
|
||||||
# Delete existing assets with same name before uploading
|
|
||||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
|
||||||
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
|
|
||||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
|
||||||
import sys,json
|
|
||||||
assets = json.load(sys.stdin)
|
|
||||||
for a in assets:
|
|
||||||
if a['name'] == '${ASSET_FILE}':
|
|
||||||
print(a['id']); break
|
|
||||||
" 2>/dev/null || true)
|
|
||||||
if [ -n "$ASSET_ID" ]; then
|
|
||||||
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Upload both formats
|
|
||||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"/tmp/${PACKAGE_NAME}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
|
|
||||||
|
|
||||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"/tmp/${TAR_NAME}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
SHA256=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -- Build the new entry (canonical format matching release.yml) --
|
|
||||||
NEW_ENTRY=""
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <update>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
|
|
||||||
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
|
|
||||||
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
|
|
||||||
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
|
|
||||||
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} </update>"
|
|
||||||
|
|
||||||
# -- Write new entry to temp file --------------------------------
|
|
||||||
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
|
|
||||||
|
|
||||||
# -- Merge into updates.xml ----------------------------------------
|
|
||||||
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
|
|
||||||
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
|
|
||||||
TARGETS=""
|
|
||||||
for entry in $CASCADE_MAP; do
|
|
||||||
key="${entry%%:*}"
|
|
||||||
vals="${entry#*:}"
|
|
||||||
if [ "$key" = "${STABILITY}" ]; then
|
|
||||||
TARGETS="$vals"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
|
|
||||||
|
|
||||||
echo "Cascade: ${STABILITY} → ${TARGETS}"
|
|
||||||
|
|
||||||
# Create updates.xml if missing
|
|
||||||
if [ ! -f "updates.xml" ]; then
|
|
||||||
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
|
|
||||||
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
|
|
||||||
printf '%s\n' "<updates>" >> updates.xml
|
|
||||||
printf '%s\n' "</updates>" >> updates.xml
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update existing blocks or create missing ones
|
|
||||||
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
|
|
||||||
python3 << 'PYEOF'
|
|
||||||
import re, os
|
|
||||||
|
|
||||||
targets = os.environ["PY_TARGETS"].split(",")
|
|
||||||
version = os.environ["PY_VERSION"]
|
|
||||||
date = os.environ["PY_DATE"]
|
|
||||||
|
|
||||||
with open("updates.xml") as f:
|
|
||||||
content = f.read()
|
|
||||||
with open("/tmp/new_entry.xml") as f:
|
|
||||||
new_entry_template = f.read()
|
|
||||||
|
|
||||||
for tag in targets:
|
|
||||||
tag = tag.strip()
|
|
||||||
# Build entry with this tag's name
|
|
||||||
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
|
|
||||||
|
|
||||||
# Try to find existing block (handles both single-line and multi-line <tags>)
|
|
||||||
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
|
|
||||||
match = re.search(block_pattern, content, re.DOTALL)
|
|
||||||
|
|
||||||
if match:
|
|
||||||
# Update in place — replace entire block
|
|
||||||
content = content.replace(match.group(1), new_entry.strip())
|
|
||||||
print(f" UPDATED: <tag>{tag}</tag> → {version}")
|
|
||||||
else:
|
|
||||||
# Create — insert before </updates>
|
|
||||||
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
|
|
||||||
print(f" CREATED: <tag>{tag}</tag> → {version}")
|
|
||||||
|
|
||||||
# Clean up excessive blank lines
|
|
||||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
|
||||||
|
|
||||||
with open("updates.xml", "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
# Commit
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
git add updates.xml
|
|
||||||
git diff --cached --quiet || {
|
|
||||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
|
||||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
|
||||||
git push
|
|
||||||
}
|
|
||||||
|
|
||||||
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
|
||||||
- name: Sync updates.xml to main
|
|
||||||
if: github.ref_name != 'main'
|
|
||||||
run: |
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
|
||||||
|
|
||||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
|
||||||
CONTENT=$(base64 -w0 updates.xml)
|
|
||||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API_BASE}/contents/updates.xml" \
|
|
||||||
-d "$(python3 -c "import json; print(json.dumps({
|
|
||||||
'content': '${CONTENT}',
|
|
||||||
'sha': '${FILE_SHA}',
|
|
||||||
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
|
||||||
'branch': 'main'
|
|
||||||
}))")" > /dev/null 2>&1 \
|
|
||||||
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
|
||||||
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: SFTP deploy to dev server
|
|
||||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
|
||||||
env:
|
|
||||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
|
||||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
|
||||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
|
||||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
|
||||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
|
||||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
|
||||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
# -- Permission check: admin or maintain role required --------
|
|
||||||
ACTOR="${{ github.actor }}"
|
|
||||||
REPO="${{ github.repository }}"
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
|
|
||||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
|
||||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
|
||||||
case "$PERMISSION" in
|
|
||||||
admin|maintain|write) ;;
|
|
||||||
*)
|
|
||||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
|
||||||
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
|
||||||
|
|
||||||
PORT="${DEV_PORT:-22}"
|
|
||||||
REMOTE="${DEV_PATH%/}"
|
|
||||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
|
||||||
|
|
||||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
|
||||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
|
||||||
if [ -n "$DEV_KEY" ]; then
|
|
||||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
|
||||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
|
||||||
else
|
|
||||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
|
||||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
|
||||||
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
|
||||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
|
||||||
<identity>
|
<identity>
|
||||||
<name>Template-Joomla</name>
|
<name>MokoDoliJoomShop</name>
|
||||||
<org>MokoConsulting</org>
|
<org>MokoConsulting</org>
|
||||||
<description>Template repository for Joomla extensions (plugins, modules, components, templates)</description>
|
<description>Joomla storefront component backed by Dolibarr products and invoicing</description>
|
||||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||||
</identity>
|
</identity>
|
||||||
<governance>
|
<governance>
|
||||||
@@ -18,7 +13,7 @@
|
|||||||
</governance>
|
</governance>
|
||||||
<build>
|
<build>
|
||||||
<language>PHP</language>
|
<language>PHP</language>
|
||||||
<package-type>joomla-extension</package-type>
|
<package-type>joomla-component</package-type>
|
||||||
<entry-point>src/</entry-point>
|
<entry-point>src/</entry-point>
|
||||||
</build>
|
</build>
|
||||||
</moko-platform>
|
</moko-platform>
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: moko-platform.Release
|
||||||
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
|
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||||
|
# VERSION: 05.00.00
|
||||||
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
|
#
|
||||||
|
# +========================================================================+
|
||||||
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||||
|
# +========================================================================+
|
||||||
|
# | |
|
||||||
|
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||||
|
# | |
|
||||||
|
# | Platform-specific: |
|
||||||
|
# | joomla: XML manifest, type-prefixed packages |
|
||||||
|
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||||
|
# | generic: README-only, no update stream |
|
||||||
|
# | |
|
||||||
|
# +========================================================================+
|
||||||
|
|
||||||
|
name: "Universal: Build & Release"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, closed]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
action:
|
||||||
|
description: 'Action to perform'
|
||||||
|
required: false
|
||||||
|
type: choice
|
||||||
|
default: release
|
||||||
|
options:
|
||||||
|
- release
|
||||||
|
- promote-rc
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||||
|
promote-rc:
|
||||||
|
name: Promote to RC
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup moko-platform tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
run: |
|
||||||
|
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||||
|
echo Using pre-installed /opt/moko-platform
|
||||||
|
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo Falling back to fresh clone
|
||||||
|
if ! command -v composer > /dev/null 2>&1; 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
|
||||||
|
rm -rf /tmp/moko-platform-api
|
||||||
|
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||||
|
git clone --depth 1 --branch main --quiet $CLONE_URL /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: Rename branch to rc
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/branch_rename.php \
|
||||||
|
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||||
|
--pr "${{ github.event.pull_request.number }}"
|
||||||
|
|
||||||
|
- name: Checkout rc and configure git
|
||||||
|
run: |
|
||||||
|
git fetch origin rc
|
||||||
|
git checkout rc
|
||||||
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
|
git config --local user.name "gitea-actions[bot]"
|
||||||
|
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|
||||||
|
- name: Publish RC release
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/release_publish.php \
|
||||||
|
--path . --stability rc --bump minor --branch rc \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||||
|
release:
|
||||||
|
name: Build & Release Pipeline
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == true ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure git for bot pushes
|
||||||
|
run: |
|
||||||
|
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||||
|
git config --local user.name "gitea-actions[bot]"
|
||||||
|
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
|
||||||
|
- name: Check for merge conflict markers
|
||||||
|
run: |
|
||||||
|
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||||
|
if [ -n "$CONFLICTS" ]; then
|
||||||
|
echo "::error::Merge conflict markers found — aborting release"
|
||||||
|
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "No conflict markers found"
|
||||||
|
|
||||||
|
- name: Setup moko-platform tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||||
|
run: |
|
||||||
|
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||||
|
echo Using pre-installed /opt/moko-platform
|
||||||
|
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo Falling back to fresh clone
|
||||||
|
if ! command -v composer > /dev/null 2>&1; 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
|
||||||
|
rm -rf /tmp/moko-platform-api
|
||||||
|
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||||
|
git clone --depth 1 --branch main --quiet $CLONE_URL /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: "Publish stable release"
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/release_publish.php \
|
||||||
|
--path . --stability stable --bump minor --branch main \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Update release notes from CHANGELOG.md
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|
||||||
|
# Extract [Unreleased] section from changelog
|
||||||
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
|
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||||
|
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||||
|
else
|
||||||
|
NOTES="Stable release"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update release body via API
|
||||||
|
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
python3 -c "
|
||||||
|
import json, urllib.request
|
||||||
|
body = open('/dev/stdin').read()
|
||||||
|
payload = json.dumps({'body': body}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
'${API_BASE}/releases/${RELEASE_ID}',
|
||||||
|
data=payload, method='PATCH',
|
||||||
|
headers={
|
||||||
|
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
})
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
" <<< "$NOTES"
|
||||||
|
echo "Release notes updated from CHANGELOG.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||||
|
- name: "Step 9: Mirror release to GitHub"
|
||||||
|
if: >-
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
secrets.GH_MIRROR_TOKEN != ''
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php ${MOKO_CLI}/release_mirror.php \
|
||||||
|
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
|
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||||
|
--branch main 2>&1 || true
|
||||||
|
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||||
|
- name: "Step 10: Push main to GitHub mirror"
|
||||||
|
if: >-
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
secrets.GH_MIRROR_TOKEN != ''
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
|
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||||
|
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||||
|
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||||
|
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||||
|
git fetch origin main --depth=1
|
||||||
|
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||||
|
&& echo "main branch pushed to GitHub mirror" \
|
||||||
|
|| echo "WARNING: GitHub mirror push failed"
|
||||||
|
|
||||||
|
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
# Delete rc branch (ephemeral — created by promote-rc)
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||||
|
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||||
|
|
||||||
|
# Delete dev branch
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||||
|
|
||||||
|
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||||
|
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API_BASE}/branches" \
|
||||||
|
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||||
|
|
||||||
|
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: "Step 12: Create version branch from main"
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
BRANCH_NAME="version/${VERSION}"
|
||||||
|
MAIN_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
# Delete old version branch if it exists (same version re-release)
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||||
|
|
||||||
|
# Create version/XX.YY.ZZ from main
|
||||||
|
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||||
|
|
||||||
|
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||||
|
- name: "Post-release: Reset dev version"
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
php ${MOKO_CLI}/version_reset_dev.php \
|
||||||
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||||
|
--branch dev --path . 2>&1 || true
|
||||||
|
|
||||||
|
# -- Summary --------------------------------------------------------------
|
||||||
|
- name: Pipeline Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||||
|
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||||
|
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||||
|
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
@@ -43,9 +43,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Clone MokoStandards
|
- name: Clone MokoStandards
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
if [ -f "composer.json" ]; then
|
if [ -f "composer.json" ]; then
|
||||||
composer install \
|
composer install \
|
||||||
@@ -346,7 +346,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
if [ -f "composer.json" ]; then
|
if [ -f "composer.json" ]; then
|
||||||
composer install \
|
composer install \
|
||||||
@@ -391,7 +391,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
if [ -f "composer.json" ]; then
|
if [ -f "composer.json" ]; then
|
||||||
composer install --no-interaction --prefer-dist --optimize-autoloader
|
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Maintenance
|
# INGROUP: moko-platform.Maintenance
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/cleanup.yml
|
# PATH: /.gitea/workflows/cleanup.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||||
@@ -33,17 +33,17 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Delete merged branches
|
- name: Delete merged branches
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Merged Branch Cleanup ==="
|
echo "=== Merged Branch Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
|
||||||
# List branches via API
|
# List branches via API
|
||||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
# Check if branch is merged into main
|
# Check if branch is merged into main
|
||||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||||
echo " Deleting merged branch: ${BRANCH}"
|
echo " Deleting merged branch: ${BRANCH}"
|
||||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
fi
|
fi
|
||||||
@@ -66,20 +66,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Clean old workflow runs
|
- name: Clean old workflow runs
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Workflow Run Cleanup ==="
|
echo "=== Workflow Run Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
# Get old completed runs
|
# Get old completed runs
|
||||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API}/actions/runs?status=completed&limit=50" | \
|
"${API}/actions/runs?status=completed&limit=50" | \
|
||||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
for RUN_ID in $RUNS; do
|
for RUN_ID in $RUNS; do
|
||||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
done
|
done
|
||||||
@@ -42,10 +42,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup MokoStandards tools
|
- name: Setup MokoStandards tools
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Security
|
# INGROUP: moko-platform.Security
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /templates/workflows/gitleaks.yml.template
|
# PATH: /templates/workflows/gitleaks.yml.template
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Notifications
|
# INGROUP: moko-platform.Notifications
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/notify.yml
|
# PATH: /.gitea/workflows/notify.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||||
@@ -18,7 +18,6 @@ on:
|
|||||||
- "Joomla Build & Release"
|
- "Joomla Build & Release"
|
||||||
- "Joomla Extension CI"
|
- "Joomla Extension CI"
|
||||||
- "Deploy"
|
- "Deploy"
|
||||||
- "Cascade Main → Dev"
|
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
|
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: moko-platform.CI
|
||||||
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
|
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||||
|
# VERSION: 09.23.00
|
||||||
|
# BRIEF: PR gate — branch policy + code validation before merge
|
||||||
|
|
||||||
|
name: "Universal: PR Check"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, edited]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||||
|
branch-policy:
|
||||||
|
name: Branch Policy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check branch merge target
|
||||||
|
run: |
|
||||||
|
HEAD="${{ github.head_ref }}"
|
||||||
|
BASE="${{ github.base_ref }}"
|
||||||
|
|
||||||
|
echo "PR: ${HEAD} → ${BASE}"
|
||||||
|
|
||||||
|
ALLOWED=true
|
||||||
|
REASON=""
|
||||||
|
|
||||||
|
case "$HEAD" in
|
||||||
|
feature/*|feat/*)
|
||||||
|
if [ "$BASE" != "dev" ]; then
|
||||||
|
ALLOWED=false
|
||||||
|
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
fix/*|bugfix/*)
|
||||||
|
if [ "$BASE" != "dev" ]; then
|
||||||
|
ALLOWED=false
|
||||||
|
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
patch/*)
|
||||||
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||||
|
ALLOWED=false
|
||||||
|
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
hotfix/*)
|
||||||
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||||
|
ALLOWED=false
|
||||||
|
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
rc)
|
||||||
|
if [ "$BASE" != "main" ]; then
|
||||||
|
ALLOWED=false
|
||||||
|
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
dev)
|
||||||
|
if [ "$BASE" != "main" ]; then
|
||||||
|
ALLOWED=false
|
||||||
|
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$ALLOWED" = false ]; then
|
||||||
|
echo "::error::${REASON}"
|
||||||
|
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||||
|
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ── Code Validation ────────────────────────────────────────────────────
|
||||||
|
validate:
|
||||||
|
name: Validate PR
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check for merge conflict markers
|
||||||
|
run: |
|
||||||
|
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||||
|
if [ -n "$CONFLICTS" ]; then
|
||||||
|
echo "::error::Merge conflict markers found in source files"
|
||||||
|
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "No conflict markers found"
|
||||||
|
|
||||||
|
- name: Detect platform
|
||||||
|
id: platform
|
||||||
|
run: |
|
||||||
|
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||||
|
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||||
|
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||||
|
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||||
|
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: PHP syntax check
|
||||||
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||||
|
echo "PHP lint: ${ERRORS} error(s)"
|
||||||
|
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||||
|
|
||||||
|
- name: Joomla JEXEC guard check
|
||||||
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
# Skip vendor, node_modules, and index.html stub files
|
||||||
|
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||||
|
# Check first 10 lines for JEXEC or JPATH guard
|
||||||
|
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||||
|
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||||
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
|
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||||
|
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "JEXEC guard: OK"
|
||||||
|
|
||||||
|
- name: Joomla directory listing protection
|
||||||
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
|
run: |
|
||||||
|
MISSING=0
|
||||||
|
SOURCE_DIR="src"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||||
|
while IFS= read -r dir; do
|
||||||
|
if [ ! -f "${dir}/index.html" ]; then
|
||||||
|
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||||
|
MISSING=$((MISSING + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||||
|
if [ "$MISSING" -gt 0 ]; then
|
||||||
|
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||||
|
|
||||||
|
- name: Joomla script file and asset checks
|
||||||
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||||
|
[ -z "$MANIFEST" ] && exit 0
|
||||||
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||||
|
|
||||||
|
# Check scriptfile exists if declared
|
||||||
|
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||||
|
if [ -n "$SCRIPTFILE" ]; then
|
||||||
|
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||||
|
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Require joomla.asset.json and validate it
|
||||||
|
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$ASSET_JSON" ]; then
|
||||||
|
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
||||||
|
echo "::error::joomla.asset.json is not valid JSON"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
echo "joomla.asset.json: valid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate all XML files in src/ are well-formed
|
||||||
|
XML_ERRORS=0
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
while IFS= read -r -d '' xmlfile; do
|
||||||
|
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
||||||
|
XML_ERRORS=$((XML_ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
||||||
|
fi
|
||||||
|
if [ "$XML_ERRORS" -gt 0 ]; then
|
||||||
|
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "XML well-formedness: OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$ERRORS" -gt 0 ] && exit 1
|
||||||
|
echo "Joomla asset checks: OK"
|
||||||
|
|
||||||
|
- 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
|
||||||
|
# Block legacy raw/branch update server URLs on MokoGitea
|
||||||
|
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||||
|
if [ -n "$RAW_URLS" ]; then
|
||||||
|
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||||
|
echo "$RAW_URLS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
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: Validate Joomla language files
|
||||||
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
# Require both en-GB and en-US language directories
|
||||||
|
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$LANG_ROOT" ]; then
|
||||||
|
echo "No language/ directory found — skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
||||||
|
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
||||||
|
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that en-GB and en-US have matching .ini files
|
||||||
|
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
||||||
|
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
||||||
|
[ ! -f "$GB_INI" ] && continue
|
||||||
|
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
||||||
|
if [ ! -f "$US_INI" ]; then
|
||||||
|
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
||||||
|
[ ! -f "$US_INI" ] && continue
|
||||||
|
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
||||||
|
if [ ! -f "$GB_INI" ]; then
|
||||||
|
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find all .ini language files
|
||||||
|
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
||||||
|
if [ -z "$INI_FILES" ]; then
|
||||||
|
echo "No .ini language files found"
|
||||||
|
[ "$ERRORS" -gt 0 ] && exit 1
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
||||||
|
|
||||||
|
for FILE in $INI_FILES; do
|
||||||
|
FNAME=$(basename "$FILE")
|
||||||
|
LINENUM=0
|
||||||
|
SEEN_KEYS=""
|
||||||
|
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
LINENUM=$((LINENUM + 1))
|
||||||
|
|
||||||
|
# Skip empty lines and comments
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
echo "$line" | grep -qE '^\s*;' && continue
|
||||||
|
echo "$line" | grep -qE '^\s*$' && continue
|
||||||
|
|
||||||
|
# Must match KEY="VALUE" format
|
||||||
|
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
||||||
|
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract key and check for duplicates
|
||||||
|
KEY=$(echo "$line" | sed 's/=.*//')
|
||||||
|
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
||||||
|
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
SEEN_KEYS="${SEEN_KEYS}
|
||||||
|
${KEY}"
|
||||||
|
done < "$FILE"
|
||||||
|
|
||||||
|
echo " ${FILE}: checked ${LINENUM} lines"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Cross-check en-GB vs en-US key consistency
|
||||||
|
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
||||||
|
for GB_FILE in "$GB_DIR"/*.ini; do
|
||||||
|
[ ! -f "$GB_FILE" ] && continue
|
||||||
|
FNAME=$(basename "$GB_FILE")
|
||||||
|
US_FILE="$US_DIR/$FNAME"
|
||||||
|
[ ! -f "$US_FILE" ] && continue
|
||||||
|
|
||||||
|
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
||||||
|
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
||||||
|
|
||||||
|
# Keys in en-GB but not en-US
|
||||||
|
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||||
|
if [ -n "$MISSING_US" ]; then
|
||||||
|
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
||||||
|
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
||||||
|
WARNINGS=$((WARNINGS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keys in en-US but not en-GB
|
||||||
|
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||||
|
if [ -n "$MISSING_GB" ]; then
|
||||||
|
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
||||||
|
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
||||||
|
WARNINGS=$((WARNINGS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Language File Validation"
|
||||||
|
echo "| Metric | Count |"
|
||||||
|
echo "|---|---|"
|
||||||
|
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
||||||
|
echo "| Errors | ${ERRORS} |"
|
||||||
|
echo "| Warnings | ${WARNINGS} |"
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
|
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Language files: OK (${WARNINGS} warning(s))"
|
||||||
|
|
||||||
|
- 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."
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# 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: /templates/workflows/universal/pre-release.yml.template
|
||||||
|
# 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
|
||||||
|
pull_request_target:
|
||||||
|
types: [synchronize, opened, reopened]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
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 || 'development' }})"
|
||||||
|
runs-on: release
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
|
||||||
|
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||||
|
|
||||||
|
- name: Setup moko-platform tools
|
||||||
|
env:
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
|
run: |
|
||||||
|
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||||
|
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||||
|
echo Using pre-installed /opt/moko-platform
|
||||||
|
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo Falling back to fresh clone
|
||||||
|
if ! command -v composer > /dev/null 2>&1; 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
|
||||||
|
rm -rf /tmp/moko-platform-api
|
||||||
|
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||||
|
git clone --depth 1 --branch main --quiet $CLONE_URL /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: Detect platform
|
||||||
|
id: platform
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
|
|
||||||
|
- name: Resolve metadata and bump version
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
|
||||||
|
STABILITY="release-candidate"
|
||||||
|
else
|
||||||
|
STABILITY="${{ inputs.stability || 'development' }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||||
|
case "$STABILITY" in
|
||||||
|
release-candidate) BUMP="minor" ;;
|
||||||
|
*) BUMP="patch" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||||
|
|
||||||
|
# Set stability suffix and verify consistency
|
||||||
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||||
|
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||||
|
|
||||||
|
php ${MOKO_CLI}/version_set_platform.php \
|
||||||
|
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||||
|
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||||
|
|
||||||
|
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
|
||||||
|
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
|
||||||
|
|
||||||
|
# Append suffix for output
|
||||||
|
if [ -n "$SUFFIX" ]; then
|
||||||
|
VERSION="${VERSION}${SUFFIX}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||||
|
git add -A
|
||||||
|
git diff --cached --quiet || {
|
||||||
|
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||||
|
git push origin HEAD 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-detect element via manifest_element.php
|
||||||
|
php ${MOKO_CLI}/manifest_element.php \
|
||||||
|
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||||
|
--repo "${GITEA_REPO}" --github-output
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
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 "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Update release notes from CHANGELOG.md
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|
||||||
|
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||||
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
|
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||||
|
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||||
|
else
|
||||||
|
NOTES="Release ${VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update release body via API
|
||||||
|
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
|
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
python3 -c "
|
||||||
|
import json, urllib.request
|
||||||
|
body = open('/dev/stdin').read()
|
||||||
|
payload = json.dumps({'body': body}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
'${API_BASE}/releases/${RELEASE_ID}',
|
||||||
|
data=payload, method='PATCH',
|
||||||
|
headers={
|
||||||
|
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
})
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
" <<< "$NOTES"
|
||||||
|
echo "Release notes updated from CHANGELOG.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# updates.xml is generated dynamically by MokoGitea license server
|
||||||
|
# No need to build, commit, or sync updates.xml from workflows
|
||||||
|
|
||||||
|
- 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.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 }}"
|
||||||
|
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
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Security
|
# INGROUP: moko-platform.Security
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/security-audit.yml
|
# PATH: /.gitea/workflows/security-audit.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||||
@@ -80,3 +80,19 @@ jobs:
|
|||||||
-H "Priority: high" \
|
-H "Priority: high" \
|
||||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||||
|
|
||||||
|
|
||||||
|
- name: Joomla version audit
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
|
||||||
|
echo "$JOOMLA_SITES" > /tmp/sites.json
|
||||||
|
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
|
||||||
|
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
|
||||||
|
rm -f /tmp/sites.json
|
||||||
|
else
|
||||||
|
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
|
||||||
|
|
||||||
+51
-6
@@ -5,13 +5,58 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
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).
|
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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial component scaffold with Dolibarr REST API client
|
- Initial component scaffold with Dolibarr REST API client
|
||||||
- Admin dashboard with connection status
|
|
||||||
- Admin views: products, orders, customers (placeholders)
|
|
||||||
- Site views: products catalog, product detail, cart, checkout (placeholders)
|
|
||||||
- Database schema for cart, orders, and customer mapping
|
|
||||||
- Component parameters for Dolibarr connection and shop settings
|
|
||||||
- Language files (en-GB)
|
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Automation.CI
|
||||||
|
# INGROUP: moko-platform.Automation
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /automation/ci-issue-reporter.sh
|
||||||
|
# VERSION: 09.23.00
|
||||||
|
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||||
|
# Deduplicates by searching open issues with the "ci-auto" label
|
||||||
|
# whose title matches the gate. If a matching issue exists, a comment
|
||||||
|
# is appended instead of opening a duplicate.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||||
|
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||||
|
REPO="${GITHUB_REPOSITORY:-}"
|
||||||
|
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||||
|
LABEL_NAME="ci-auto"
|
||||||
|
LABEL_COLOR="#e11d48"
|
||||||
|
|
||||||
|
GATE=""
|
||||||
|
DETAILS=""
|
||||||
|
SEVERITY="error"
|
||||||
|
WORKFLOW=""
|
||||||
|
|
||||||
|
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||||
|
--details Human-readable failure description
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
--severity "error" (default) or "warning"
|
||||||
|
--workflow Workflow name for the issue title
|
||||||
|
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||||
|
--run-url URL to the CI run (auto-detected from env)
|
||||||
|
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||||
|
--url Gitea base URL (default: \$GITEA_URL)
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--gate) GATE="$2"; shift 2 ;;
|
||||||
|
--details) DETAILS="$2"; shift 2 ;;
|
||||||
|
--severity) SEVERITY="$2"; shift 2 ;;
|
||||||
|
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||||
|
--repo) REPO="$2"; shift 2 ;;
|
||||||
|
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||||
|
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||||
|
--url) GITEA_URL="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "Unknown option: $1"; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||||
|
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||||
|
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||||
|
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||||
|
|
||||||
|
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||||
|
|
||||||
|
# ── Build title ─────────────────────────────────────────────────────────────
|
||||||
|
if [[ -n "$WORKFLOW" ]]; then
|
||||||
|
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||||
|
else
|
||||||
|
TITLE="[CI] ${GATE} failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||||
|
ensure_label() {
|
||||||
|
local exists
|
||||||
|
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/labels" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [[ "$exists" == "200" ]]; then
|
||||||
|
# Check if label already exists
|
||||||
|
local found
|
||||||
|
found=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/labels" 2>/dev/null \
|
||||||
|
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||||
|
|
||||||
|
if [[ -z "$found" ]]; then
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/labels" \
|
||||||
|
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Search for existing open issue ──────────────────────────────────────────
|
||||||
|
find_existing_issue() {
|
||||||
|
# URL-encode the gate name for the query
|
||||||
|
local query
|
||||||
|
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||||
|
2>/dev/null || echo "[]")
|
||||||
|
|
||||||
|
# Extract the first matching issue number
|
||||||
|
echo "$response" \
|
||||||
|
| grep -oP '"number":\s*\K[0-9]+' \
|
||||||
|
| head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Build issue body ────────────────────────────────────────────────────────
|
||||||
|
build_body() {
|
||||||
|
local severity_badge
|
||||||
|
if [[ "$SEVERITY" == "error" ]]; then
|
||||||
|
severity_badge="**Severity:** Error"
|
||||||
|
else
|
||||||
|
severity_badge="**Severity:** Warning"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<BODY
|
||||||
|
## CI Gate Failure: ${GATE}
|
||||||
|
|
||||||
|
${severity_badge}
|
||||||
|
**Workflow:** ${WORKFLOW:-unknown}
|
||||||
|
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||||
|
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||||
|
**Run:** [View CI run](${RUN_URL})
|
||||||
|
|
||||||
|
### Details
|
||||||
|
|
||||||
|
${DETAILS}
|
||||||
|
|
||||||
|
### Resolution
|
||||||
|
|
||||||
|
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||||
|
BODY
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||||
|
build_comment() {
|
||||||
|
cat <<COMMENT
|
||||||
|
### CI failure recurrence
|
||||||
|
|
||||||
|
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||||
|
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||||
|
**Run:** [View CI run](${RUN_URL})
|
||||||
|
|
||||||
|
${DETAILS}
|
||||||
|
COMMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
ensure_label
|
||||||
|
|
||||||
|
EXISTING=$(find_existing_issue)
|
||||||
|
|
||||||
|
if [[ -n "$EXISTING" ]]; then
|
||||||
|
# Append comment to existing issue
|
||||||
|
COMMENT_BODY=$(build_comment)
|
||||||
|
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||||
|
|
||||||
|
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/issues/${EXISTING}/comments" \
|
||||||
|
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [[ "$HTTP" == "201" ]]; then
|
||||||
|
echo "Commented on existing issue #${EXISTING}"
|
||||||
|
else
|
||||||
|
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Create new issue
|
||||||
|
ISSUE_BODY=$(build_body)
|
||||||
|
ISSUE_JSON=$(python3 -c "
|
||||||
|
import sys, json
|
||||||
|
body = sys.stdin.read()
|
||||||
|
print(json.dumps({
|
||||||
|
'title': sys.argv[1],
|
||||||
|
'body': body,
|
||||||
|
'labels': []
|
||||||
|
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||||
|
|
||||||
|
# Create the issue
|
||||||
|
RESPONSE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/issues" \
|
||||||
|
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||||
|
|
||||||
|
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||||
|
|
||||||
|
if [[ -n "$ISSUE_NUM" ]]; then
|
||||||
|
# Apply label (separate call — more reliable across Gitea versions)
|
||||||
|
LABEL_ID=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/labels" 2>/dev/null \
|
||||||
|
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||||
|
| head -1 || true)
|
||||||
|
|
||||||
|
if [[ -n "$LABEL_ID" ]]; then
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||||
|
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||||
|
else
|
||||||
|
echo "WARNING: Failed to create issue"
|
||||||
|
echo "Response: ${RESPONSE}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* MokoDoliJoomShop - Responsive Storefront Styles
|
||||||
|
* Mobile-first responsive layout for all storefront views.
|
||||||
|
*
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Base / Mobile-first (320px+)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-products,
|
||||||
|
.com-mokodolijoomshop-product,
|
||||||
|
.com-mokodolijoomshop-cart,
|
||||||
|
.com-mokodolijoomshop-checkout,
|
||||||
|
.com-mokodolijoomshop-category,
|
||||||
|
.com-mokodolijoomshop-orders {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product cards */
|
||||||
|
.product-card {
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card .card-title a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card .card-title a:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product gallery */
|
||||||
|
.product-gallery img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cart table mobile */
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.com-mokodolijoomshop-cart .table-responsive table {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-cart .table th,
|
||||||
|
.com-mokodolijoomshop-cart .table td {
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Tablet (576px+)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (min-width: 576px) {
|
||||||
|
.product-card .card-body {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Desktop (992px+)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.product-card .card-body {
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Mobile Product Detail - Sticky Add to Cart
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.com-mokodolijoomshop-product .input-group {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-product form[action*="cart.add"] {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bs-body-bg, #fff);
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkout form columns stack on mobile */
|
||||||
|
.com-mokodolijoomshop-checkout .row > .col-md-7,
|
||||||
|
.com-mokodolijoomshop-checkout .row > .col-md-5 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Category Sidebar
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-category .list-group-item a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-category .list-group-item a:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-category .list-group-item.active a {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested category lists */
|
||||||
|
.com-mokodolijoomshop-category .list-group .list-group {
|
||||||
|
margin-left: 1rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Shop Categories Navigation (products view)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.shop-categories {
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Touch-friendly Cart Controls
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-cart input[type="number"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-cart .btn-outline-danger {
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Order Status Badges
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.com-mokodolijoomshop-orders .badge {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Print Styles
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.shop-categories,
|
||||||
|
.com-mokodolijoomshop-product form,
|
||||||
|
.com-mokodolijoomshop-cart .btn,
|
||||||
|
.pagination {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,5 +27,66 @@ COM_MOKODOLIJOOMSHOP_FIELD_TAX_ENABLED="Enable Tax"
|
|||||||
|
|
||||||
COM_MOKODOLIJOOMSHOP_CONNECTION_OK="Dolibarr connection successful"
|
COM_MOKODOLIJOOMSHOP_CONNECTION_OK="Dolibarr connection successful"
|
||||||
COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED="Dolibarr connection failed. Check URL and API key."
|
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_PRODUCTS="Sync Products"
|
||||||
COM_MOKODOLIJOOMSHOP_SYNC_COMPLETE="Product sync complete: %d products updated"
|
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"
|
||||||
|
|||||||
@@ -45,6 +45,52 @@ CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_orders` (
|
|||||||
KEY `idx_status` (`status`)
|
KEY `idx_status` (`status`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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)
|
-- Customer mapping (links Joomla users to Dolibarr thirdparties)
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_customers` (
|
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_customers` (
|
||||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@@ -6,3 +6,6 @@
|
|||||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_cart`;
|
DROP TABLE IF EXISTS `#__mokodolijoomshop_cart`;
|
||||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_orders`;
|
DROP TABLE IF EXISTS `#__mokodolijoomshop_orders`;
|
||||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_customers`;
|
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`;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\Component\ComponentHelper;
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
use Joomla\CMS\Http\HttpFactory;
|
use Joomla\CMS\Http\HttpFactory;
|
||||||
use Joomla\CMS\Log\Log;
|
use Joomla\CMS\Log\Log;
|
||||||
|
use Joomla\Registry\Registry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP client for the Dolibarr REST API.
|
* HTTP client for the Dolibarr REST API.
|
||||||
@@ -36,20 +37,28 @@ class DolibarrClient
|
|||||||
*/
|
*/
|
||||||
private string $apiKey;
|
private string $apiKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Whether to verify SSL certificates.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private bool $verifySSL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor. Reads connection settings from component params.
|
* Constructor. Reads connection settings from component params.
|
||||||
*
|
*
|
||||||
* @param string|null $baseUrl Override base URL.
|
* @param string|null $baseUrl Override base URL.
|
||||||
* @param string|null $apiKey Override API key.
|
* @param string|null $apiKey Override API key.
|
||||||
|
* @param bool|null $verifySSL Override SSL verification.
|
||||||
*
|
*
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
public function __construct(?string $baseUrl = null, ?string $apiKey = null)
|
public function __construct(?string $baseUrl = null, ?string $apiKey = null, ?bool $verifySSL = null)
|
||||||
{
|
{
|
||||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
|
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
|
||||||
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
|
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
|
||||||
|
$this->verifySSL = $verifySSL ?? (bool) $params->get('dolibarr_verify_ssl', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +106,20 @@ class DolibarrClient
|
|||||||
return $this->request('PUT', $endpoint, [], $data);
|
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.
|
* Test the connection to the Dolibarr API.
|
||||||
*
|
*
|
||||||
@@ -111,6 +134,82 @@ class DolibarrClient
|
|||||||
return $result !== null;
|
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.
|
* Execute an HTTP request against the Dolibarr REST API.
|
||||||
*
|
*
|
||||||
@@ -147,7 +246,13 @@ class DolibarrClient
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$http = HttpFactory::getHttp();
|
$options = new Registry();
|
||||||
|
$options->set('transport.curl', [
|
||||||
|
CURLOPT_SSL_VERIFYPEER => $this->verifySSL,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => $this->verifySSL ? 2 : 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$http = HttpFactory::getHttp($options);
|
||||||
$jsonBody = !empty($body) ? json_encode($body) : null;
|
$jsonBody = !empty($body) ? json_encode($body) : null;
|
||||||
|
|
||||||
switch (strtoupper($method))
|
switch (strtoupper($method))
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard model — provides metrics and sync data for the admin dashboard.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class DashboardModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param array $config Configuration array.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct($config = [])
|
||||||
|
{
|
||||||
|
parent::__construct($config);
|
||||||
|
|
||||||
|
$this->client = new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product count from Dolibarr.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getProductCount(): int
|
||||||
|
{
|
||||||
|
$products = $this->client->get('/products', ['limit' => 0]);
|
||||||
|
|
||||||
|
return $products !== null ? \count($products) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get local order count.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getOrderCount(): int
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return (int) $db->loadResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer mapping count.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getCustomerCount(): int
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_customers'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return (int) $db->loadResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent orders from local table.
|
||||||
|
*
|
||||||
|
* @param int $limit Number of recent orders.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getRecentOrders(int $limit = 10): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->order($db->quoteName('created') . ' DESC');
|
||||||
|
$db->setQuery($query, 0, $limit);
|
||||||
|
|
||||||
|
return $db->loadAssocList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get revenue metrics.
|
||||||
|
*
|
||||||
|
* @return array{today: float, week: float, month: float}
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getRevenue(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$now = Factory::getDate();
|
||||||
|
$today = $now->format('Y-m-d');
|
||||||
|
$week = Factory::getDate('-7 days')->format('Y-m-d');
|
||||||
|
$month = Factory::getDate('-30 days')->format('Y-m-d');
|
||||||
|
|
||||||
|
$revenue = ['today' => 0.0, 'week' => 0.0, 'month' => 0.0];
|
||||||
|
|
||||||
|
// Today
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->where($db->quoteName('created') . ' >= ' . $db->quote($today . ' 00:00:00'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
$revenue['today'] = (float) $db->loadResult();
|
||||||
|
|
||||||
|
// Week
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->where($db->quoteName('created') . ' >= ' . $db->quote($week . ' 00:00:00'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
$revenue['week'] = (float) $db->loadResult();
|
||||||
|
|
||||||
|
// Month
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->where($db->quoteName('created') . ' >= ' . $db->quote($month . ' 00:00:00'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
$revenue['month'] = (float) $db->loadResult();
|
||||||
|
|
||||||
|
return $revenue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin orders model — lists and manages orders from local mapping table.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class OrdersModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param array $config Configuration array.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct($config = [])
|
||||||
|
{
|
||||||
|
parent::__construct($config);
|
||||||
|
|
||||||
|
$this->client = new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all orders with optional filters.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getItems(): array
|
||||||
|
{
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$status = $app->input->getString('filter_status', '');
|
||||||
|
$dateFrom = $app->input->getString('filter_date_from', '');
|
||||||
|
$dateTo = $app->input->getString('filter_date_to', '');
|
||||||
|
$search = $app->input->getString('filter_search', '');
|
||||||
|
|
||||||
|
$query->select('o.*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders', 'o'))
|
||||||
|
->order($db->quoteName('o.created') . ' DESC');
|
||||||
|
|
||||||
|
if (!empty($status))
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('o.status') . ' = ' . $db->quote($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($dateFrom))
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('o.created') . ' >= ' . $db->quote($dateFrom . ' 00:00:00'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($dateTo))
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('o.created') . ' <= ' . $db->quote($dateTo . ' 23:59:59'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($search))
|
||||||
|
{
|
||||||
|
$searchQuoted = $db->quote('%' . $search . '%');
|
||||||
|
$query->where(
|
||||||
|
'(' . $db->quoteName('o.order_ref') . ' LIKE ' . $searchQuoted
|
||||||
|
. ' OR ' . $db->quoteName('o.invoice_ref') . ' LIKE ' . $searchQuoted . ')'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
$orders = $db->loadAssocList() ?: [];
|
||||||
|
|
||||||
|
// Enrich with user names
|
||||||
|
foreach ($orders as &$order)
|
||||||
|
{
|
||||||
|
if ((int) $order['user_id'] > 0)
|
||||||
|
{
|
||||||
|
$userQuery = $db->getQuery(true);
|
||||||
|
$userQuery->select($db->quoteName('name'))
|
||||||
|
->from($db->quoteName('#__users'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . (int) $order['user_id']);
|
||||||
|
$db->setQuery($userQuery);
|
||||||
|
$order['customer_name'] = $db->loadResult() ?: 'User #' . $order['user_id'];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$order['customer_name'] = 'Guest';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($order);
|
||||||
|
|
||||||
|
return $orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync order status from Dolibarr for a specific order.
|
||||||
|
*
|
||||||
|
* @param int $localOrderId Local order table ID.
|
||||||
|
*
|
||||||
|
* @return string|null Updated status, or null on failure.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function syncOrderStatus(int $localOrderId): ?string
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $localOrderId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$local = $db->loadAssoc();
|
||||||
|
|
||||||
|
if (empty($local) || empty($local['dolibarr_order_id']))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = $this->client->get('/orders/' . (int) $local['dolibarr_order_id']);
|
||||||
|
|
||||||
|
if ($order === null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusMap = [
|
||||||
|
-1 => 'cancelled',
|
||||||
|
0 => 'draft',
|
||||||
|
1 => 'validated',
|
||||||
|
2 => 'shipped',
|
||||||
|
3 => 'delivered',
|
||||||
|
];
|
||||||
|
|
||||||
|
$statusCode = (int) ($order['statut'] ?? $order['status'] ?? 0);
|
||||||
|
$newStatus = $statusMap[$statusCode] ?? 'unknown';
|
||||||
|
|
||||||
|
// Update local status
|
||||||
|
$update = $db->getQuery(true);
|
||||||
|
$update->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->set($db->quoteName('status') . ' = ' . $db->quote($newStatus))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $localOrderId);
|
||||||
|
$db->setQuery($update);
|
||||||
|
$db->execute();
|
||||||
|
|
||||||
|
return $newStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Cache\CacheControllerFactoryInterface;
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API response cache service using Joomla's cache framework.
|
||||||
|
*
|
||||||
|
* Caches Dolibarr API responses to reduce load and improve performance.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CacheService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string Cache group name.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private const GROUP = 'com_mokodolijoomshop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached value or execute the callback and cache the result.
|
||||||
|
*
|
||||||
|
* @param string $key Cache key.
|
||||||
|
* @param callable $callback Function to call if cache miss.
|
||||||
|
* @param int|null $ttl Time-to-live in seconds (null = use default).
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function remember(string $key, callable $callback, ?int $ttl = null)
|
||||||
|
{
|
||||||
|
if (!self::isEnabled())
|
||||||
|
{
|
||||||
|
return $callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = self::getCache($ttl);
|
||||||
|
$id = md5($key);
|
||||||
|
|
||||||
|
$result = $cache->get($id, self::GROUP);
|
||||||
|
|
||||||
|
if ($result !== false)
|
||||||
|
{
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $callback();
|
||||||
|
$cache->store($result, $id, self::GROUP);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate a specific cache key.
|
||||||
|
*
|
||||||
|
* @param string $key Cache key to invalidate.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function forget(string $key): void
|
||||||
|
{
|
||||||
|
$cache = self::getCache();
|
||||||
|
$cache->remove(md5($key), self::GROUP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all component cache (used during manual sync).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function flush(): void
|
||||||
|
{
|
||||||
|
$cache = self::getCache();
|
||||||
|
$cache->clean(self::GROUP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if caching is enabled.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function isEnabled(): bool
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return (bool) $params->get('cache_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default TTL in seconds.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function getDefaultTtl(): int
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return (int) $params->get('cache_ttl', 900); // 15 minutes default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a Joomla cache controller.
|
||||||
|
*
|
||||||
|
* @param int|null $ttl TTL override in seconds.
|
||||||
|
*
|
||||||
|
* @return \Joomla\CMS\Cache\CacheController
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private static function getCache(?int $ttl = null)
|
||||||
|
{
|
||||||
|
$options = [
|
||||||
|
'defaultgroup' => self::GROUP,
|
||||||
|
'caching' => true,
|
||||||
|
'lifetime' => ($ttl ?? self::getDefaultTtl()) / 60,
|
||||||
|
];
|
||||||
|
|
||||||
|
return Factory::getContainer()
|
||||||
|
->get(CacheControllerFactoryInterface::class)
|
||||||
|
->createCacheController('output', $options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
use Joomla\CMS\User\User;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs Joomla users to Dolibarr thirdparties (customers).
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CustomerSyncService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param DolibarrClient|null $client Optional client override.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct(?DolibarrClient $client = null)
|
||||||
|
{
|
||||||
|
$this->client = $client ?? new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a Dolibarr thirdparty for the given Joomla user.
|
||||||
|
*
|
||||||
|
* Checks the local mapping table first, then searches Dolibarr by email,
|
||||||
|
* and finally creates a new thirdparty if none exists.
|
||||||
|
*
|
||||||
|
* @param int $userId Joomla user ID.
|
||||||
|
*
|
||||||
|
* @return int|null Dolibarr thirdparty ID, or null on failure.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getOrCreateThirdparty(int $userId): ?int
|
||||||
|
{
|
||||||
|
// Check local mapping first
|
||||||
|
$existingId = $this->getLocalMapping($userId);
|
||||||
|
|
||||||
|
if ($existingId !== null)
|
||||||
|
{
|
||||||
|
return $existingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Factory::getContainer()->get(\Joomla\CMS\User\UserFactoryInterface::class)->loadUserById($userId);
|
||||||
|
|
||||||
|
if ($user->guest || empty($user->email))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search Dolibarr by email to avoid duplicates
|
||||||
|
$existing = $this->findThirdpartyByEmail($user->email);
|
||||||
|
|
||||||
|
if ($existing !== null)
|
||||||
|
{
|
||||||
|
$this->saveMapping($userId, $existing);
|
||||||
|
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new thirdparty in Dolibarr
|
||||||
|
$thirdpartyId = $this->createThirdparty($user);
|
||||||
|
|
||||||
|
if ($thirdpartyId !== null)
|
||||||
|
{
|
||||||
|
$this->saveMapping($userId, $thirdpartyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $thirdpartyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a guest customer in Dolibarr (no Joomla user mapping).
|
||||||
|
*
|
||||||
|
* @param string $name Customer name.
|
||||||
|
* @param string $email Customer email.
|
||||||
|
* @param string $address Billing address.
|
||||||
|
* @param string $town City.
|
||||||
|
* @param string $zip Postal code.
|
||||||
|
* @param string $phone Phone number.
|
||||||
|
*
|
||||||
|
* @return int|null Dolibarr thirdparty ID.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function createGuestCustomer(
|
||||||
|
string $name,
|
||||||
|
string $email,
|
||||||
|
string $address = '',
|
||||||
|
string $town = '',
|
||||||
|
string $zip = '',
|
||||||
|
string $phone = ''
|
||||||
|
): ?int {
|
||||||
|
// Check if already exists by email
|
||||||
|
$existing = $this->findThirdpartyByEmail($email);
|
||||||
|
|
||||||
|
if ($existing !== null)
|
||||||
|
{
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'client' => 1,
|
||||||
|
'code_client' => '-1',
|
||||||
|
'address' => $address,
|
||||||
|
'town' => $town,
|
||||||
|
'zip' => $zip,
|
||||||
|
'phone' => $phone,
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->client->post('/thirdparties', $data);
|
||||||
|
|
||||||
|
if ($result === null)
|
||||||
|
{
|
||||||
|
Log::add('CustomerSyncService: Failed to create guest thirdparty for ' . $email, Log::ERROR, 'com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local mapping for a Joomla user.
|
||||||
|
*
|
||||||
|
* @param int $userId Joomla user ID.
|
||||||
|
*
|
||||||
|
* @return int|null Dolibarr thirdparty ID, or null if not mapped.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getLocalMapping(int $userId): ?int
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select($db->quoteName('dolibarr_thirdparty_id'))
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_customers'))
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
$result = $db->loadResult();
|
||||||
|
|
||||||
|
return $result !== null ? (int) $result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Dolibarr for a thirdparty matching the given email.
|
||||||
|
*
|
||||||
|
* @param string $email Email address to search.
|
||||||
|
*
|
||||||
|
* @return int|null Thirdparty ID or null.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function findThirdpartyByEmail(string $email): ?int
|
||||||
|
{
|
||||||
|
$results = $this->client->get('/thirdparties', [
|
||||||
|
'sortfield' => 't.rowid',
|
||||||
|
'sortorder' => 'ASC',
|
||||||
|
'limit' => 1,
|
||||||
|
'sqlfilters' => "(t.email:=:'" . addslashes($email) . "')",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($results) && isset($results[0]['id']))
|
||||||
|
{
|
||||||
|
return (int) $results[0]['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Dolibarr thirdparty from a Joomla user.
|
||||||
|
*
|
||||||
|
* @param User $user Joomla user object.
|
||||||
|
*
|
||||||
|
* @return int|null Created thirdparty ID.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function createThirdparty(User $user): ?int
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'client' => 1,
|
||||||
|
'code_client' => '-1',
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->client->post('/thirdparties', $data);
|
||||||
|
|
||||||
|
if ($result === null)
|
||||||
|
{
|
||||||
|
Log::add(
|
||||||
|
'CustomerSyncService: Failed to create thirdparty for user ' . $user->id,
|
||||||
|
Log::ERROR,
|
||||||
|
'com_mokodolijoomshop'
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a user ↔ thirdparty mapping in the local database.
|
||||||
|
*
|
||||||
|
* @param int $userId Joomla user ID.
|
||||||
|
* @param int $thirdpartyId Dolibarr thirdparty ID.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function saveMapping(int $userId, int $thirdpartyId): bool
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||||
|
$table = new \Moko\Component\MokoDoliJoomShop\Administrator\Table\CustomerTable($db);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'dolibarr_thirdparty_id' => $thirdpartyId,
|
||||||
|
'synced_at' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$table->bind($data);
|
||||||
|
|
||||||
|
if (!$table->check())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $table->store();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
use Joomla\CMS\Mail\MailerFactoryInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email notification service for order events.
|
||||||
|
*
|
||||||
|
* Sends customer confirmation and admin notification emails
|
||||||
|
* using Joomla's mail transport system.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class EmailService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send order confirmation email to the customer.
|
||||||
|
*
|
||||||
|
* @param string $customerEmail Customer email address.
|
||||||
|
* @param string $customerName Customer name.
|
||||||
|
* @param array $orderData Order result data (order_ref, invoice_ref).
|
||||||
|
* @param array $cartItems Cart items at time of order.
|
||||||
|
* @param array $totals Cart totals (subtotal, tax, total).
|
||||||
|
*
|
||||||
|
* @return bool True if sent successfully.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function sendCustomerConfirmation(
|
||||||
|
string $customerEmail,
|
||||||
|
string $customerName,
|
||||||
|
array $orderData,
|
||||||
|
array $cartItems,
|
||||||
|
array $totals
|
||||||
|
): bool {
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$currency = $params->get('currency', 'USD');
|
||||||
|
$siteName = Factory::getApplication()->get('sitename');
|
||||||
|
|
||||||
|
$subject = Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_SUBJECT', $orderData['order_ref'] ?? '');
|
||||||
|
|
||||||
|
$body = $this->buildCustomerEmailBody(
|
||||||
|
$customerName,
|
||||||
|
$orderData,
|
||||||
|
$cartItems,
|
||||||
|
$totals,
|
||||||
|
$currency,
|
||||||
|
$siteName
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->sendMail($customerEmail, $subject, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send order notification email to the admin.
|
||||||
|
*
|
||||||
|
* @param array $orderData Order result data.
|
||||||
|
* @param array $cartItems Cart items.
|
||||||
|
* @param array $totals Cart totals.
|
||||||
|
* @param string $customerName Customer name.
|
||||||
|
* @param string $customerEmail Customer email.
|
||||||
|
*
|
||||||
|
* @return bool True if sent successfully.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function sendAdminNotification(
|
||||||
|
array $orderData,
|
||||||
|
array $cartItems,
|
||||||
|
array $totals,
|
||||||
|
string $customerName,
|
||||||
|
string $customerEmail
|
||||||
|
): bool {
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$currency = $params->get('currency', 'USD');
|
||||||
|
$adminMail = Factory::getApplication()->get('mailfrom');
|
||||||
|
|
||||||
|
$subject = Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_ADMIN_ORDER_SUBJECT', $orderData['order_ref'] ?? '');
|
||||||
|
|
||||||
|
$body = $this->buildAdminEmailBody(
|
||||||
|
$orderData,
|
||||||
|
$cartItems,
|
||||||
|
$totals,
|
||||||
|
$currency,
|
||||||
|
$customerName,
|
||||||
|
$customerEmail
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->sendMail($adminMail, $subject, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the customer confirmation email HTML body.
|
||||||
|
*
|
||||||
|
* @param string $name Customer name.
|
||||||
|
* @param array $order Order data.
|
||||||
|
* @param array $items Cart items.
|
||||||
|
* @param array $totals Totals.
|
||||||
|
* @param string $currency Currency code.
|
||||||
|
* @param string $siteName Site name.
|
||||||
|
*
|
||||||
|
* @return string HTML email body.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function buildCustomerEmailBody(
|
||||||
|
string $name,
|
||||||
|
array $order,
|
||||||
|
array $items,
|
||||||
|
array $totals,
|
||||||
|
string $currency,
|
||||||
|
string $siteName
|
||||||
|
): string {
|
||||||
|
$orderRef = htmlspecialchars($order['order_ref'] ?? '');
|
||||||
|
$invoiceRef = htmlspecialchars($order['invoice_ref'] ?? '');
|
||||||
|
|
||||||
|
$html = '<html><body style="font-family: Arial, sans-serif; line-height: 1.6;">';
|
||||||
|
$html .= '<h2>' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_GREETING', htmlspecialchars($name)) . '</h2>';
|
||||||
|
$html .= '<p>' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_CONFIRMED') . '</p>';
|
||||||
|
$html .= '<table style="border-collapse:collapse; width:100%; margin:20px 0;">';
|
||||||
|
$html .= '<tr><td><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':</strong></td><td>' . $orderRef . '</td></tr>';
|
||||||
|
|
||||||
|
if ($invoiceRef)
|
||||||
|
{
|
||||||
|
$html .= '<tr><td><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF') . ':</strong></td><td>' . $invoiceRef . '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</table>';
|
||||||
|
|
||||||
|
// Items table
|
||||||
|
$html .= '<table style="border-collapse:collapse; width:100%; margin:20px 0; border:1px solid #ddd;">';
|
||||||
|
$html .= '<thead><tr style="background:#f5f5f5;">';
|
||||||
|
$html .= '<th style="padding:8px; text-align:left; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL') . '</th>';
|
||||||
|
$html .= '<th style="padding:8px; text-align:center; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . '</th>';
|
||||||
|
$html .= '<th style="padding:8px; text-align:right; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '</th>';
|
||||||
|
$html .= '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
foreach ($items as $item)
|
||||||
|
{
|
||||||
|
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||||
|
$html .= '<tr>';
|
||||||
|
$html .= '<td style="padding:8px; border:1px solid #ddd;">' . htmlspecialchars($item['product_label']) . '</td>';
|
||||||
|
$html .= '<td style="padding:8px; text-align:center; border:1px solid #ddd;">' . (int) $item['quantity'] . '</td>';
|
||||||
|
$html .= '<td style="padding:8px; text-align:right; border:1px solid #ddd;">' . number_format($lineTotal, 2) . ' ' . $currency . '</td>';
|
||||||
|
$html .= '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</tbody></table>';
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
$html .= '<table style="width:300px; margin-left:auto;">';
|
||||||
|
$html .= '<tr><td>' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '</td><td style="text-align:right;">' . number_format($totals['subtotal'], 2) . ' ' . $currency . '</td></tr>';
|
||||||
|
|
||||||
|
if ($totals['tax'] > 0)
|
||||||
|
{
|
||||||
|
$html .= '<tr><td>' . Text::_('COM_MOKODOLIJOOMSHOP_TAX') . '</td><td style="text-align:right;">' . number_format($totals['tax'], 2) . ' ' . $currency . '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<tr style="font-weight:bold; font-size:1.2em;"><td>' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . '</td><td style="text-align:right;">' . number_format($totals['total'], 2) . ' ' . $currency . '</td></tr>';
|
||||||
|
$html .= '</table>';
|
||||||
|
|
||||||
|
$html .= '<p style="margin-top:30px; color:#666;">' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_FOOTER', htmlspecialchars($siteName)) . '</p>';
|
||||||
|
$html .= '</body></html>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the admin notification email body.
|
||||||
|
*
|
||||||
|
* @param array $order Order data.
|
||||||
|
* @param array $items Cart items.
|
||||||
|
* @param array $totals Totals.
|
||||||
|
* @param string $currency Currency.
|
||||||
|
* @param string $customerName Customer name.
|
||||||
|
* @param string $customerEmail Customer email.
|
||||||
|
*
|
||||||
|
* @return string HTML body.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function buildAdminEmailBody(
|
||||||
|
array $order,
|
||||||
|
array $items,
|
||||||
|
array $totals,
|
||||||
|
string $currency,
|
||||||
|
string $customerName,
|
||||||
|
string $customerEmail
|
||||||
|
): string {
|
||||||
|
$html = '<html><body style="font-family: Arial, sans-serif;">';
|
||||||
|
$html .= '<h2>' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_NEW_ORDER') . '</h2>';
|
||||||
|
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':</strong> ' . htmlspecialchars($order['order_ref'] ?? '') . '</p>';
|
||||||
|
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME') . ':</strong> ' . htmlspecialchars($customerName) . ' (' . htmlspecialchars($customerEmail) . ')</p>';
|
||||||
|
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . ':</strong> ' . number_format($totals['total'], 2) . ' ' . $currency . '</p>';
|
||||||
|
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . ':</strong> ' . \count($items) . ' item(s)</p>';
|
||||||
|
$html .= '</body></html>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an HTML email using Joomla's mail system.
|
||||||
|
*
|
||||||
|
* @param string $to Recipient email.
|
||||||
|
* @param string $subject Email subject.
|
||||||
|
* @param string $body HTML body.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function sendMail(string $to, string $subject, string $body): bool
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$mailer = Factory::getContainer()->get(MailerFactoryInterface::class)->createMailer();
|
||||||
|
$mailer->addRecipient($to);
|
||||||
|
$mailer->setSubject($subject);
|
||||||
|
$mailer->setBody($body);
|
||||||
|
$mailer->isHtml(true);
|
||||||
|
|
||||||
|
return $mailer->Send();
|
||||||
|
}
|
||||||
|
catch (\Exception $e)
|
||||||
|
{
|
||||||
|
Log::add('EmailService: ' . $e->getMessage(), Log::ERROR, 'com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Filesystem\File;
|
||||||
|
use Joomla\CMS\Filesystem\Folder;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
use Joomla\CMS\Uri\Uri;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product image service — fetches, caches, and serves product images.
|
||||||
|
*
|
||||||
|
* Images are stored in: /media/com_mokodolijoomshop/images/products/{product_id}/
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class ImageService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string Base path for cached images.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private string $basePath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Base URL for cached images.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private string $baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Placeholder image path.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private const PLACEHOLDER = 'media/com_mokodolijoomshop/images/placeholder.png';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param DolibarrClient|null $client Optional client override.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct(?DolibarrClient $client = null)
|
||||||
|
{
|
||||||
|
$this->client = $client ?? new DolibarrClient();
|
||||||
|
$this->basePath = JPATH_ROOT . '/media/com_mokodolijoomshop/images/products';
|
||||||
|
$this->baseUrl = Uri::root() . 'media/com_mokodolijoomshop/images/products';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image URLs for a product, fetching from Dolibarr if not cached.
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
*
|
||||||
|
* @return array Array of image URLs (local cached paths).
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getProductImages(int $productId): array
|
||||||
|
{
|
||||||
|
$productDir = $this->basePath . '/' . $productId;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (is_dir($productDir))
|
||||||
|
{
|
||||||
|
$files = Folder::files($productDir, '\.(jpe?g|png|gif|webp)$', false, true);
|
||||||
|
|
||||||
|
if (!empty($files))
|
||||||
|
{
|
||||||
|
return array_map(function ($file) use ($productId) {
|
||||||
|
return $this->baseUrl . '/' . $productId . '/' . basename($file);
|
||||||
|
}, $files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from Dolibarr
|
||||||
|
return $this->fetchAndCache($productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single thumbnail URL for list views.
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
*
|
||||||
|
* @return string Image URL or placeholder.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getThumbnail(int $productId): string
|
||||||
|
{
|
||||||
|
$images = $this->getProductImages($productId);
|
||||||
|
|
||||||
|
if (empty($images))
|
||||||
|
{
|
||||||
|
return Uri::root() . self::PLACEHOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first image as thumbnail
|
||||||
|
return $images[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the placeholder image URL.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getPlaceholder(): string
|
||||||
|
{
|
||||||
|
return Uri::root() . self::PLACEHOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate the image cache for a product (used during sync).
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function invalidateCache(int $productId): bool
|
||||||
|
{
|
||||||
|
$productDir = $this->basePath . '/' . $productId;
|
||||||
|
|
||||||
|
if (is_dir($productDir))
|
||||||
|
{
|
||||||
|
return Folder::delete($productDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all cached images.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function invalidateAll(): bool
|
||||||
|
{
|
||||||
|
if (is_dir($this->basePath))
|
||||||
|
{
|
||||||
|
return Folder::delete($this->basePath) && Folder::create($this->basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch product images from Dolibarr and cache them locally.
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
*
|
||||||
|
* @return array Array of cached image URLs.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function fetchAndCache(int $productId): array
|
||||||
|
{
|
||||||
|
$docs = $this->client->get('/documents', [
|
||||||
|
'modulepart' => 'product',
|
||||||
|
'id' => $productId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (empty($docs) || !\is_array($docs))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$productDir = $this->basePath . '/' . $productId;
|
||||||
|
|
||||||
|
if (!is_dir($productDir))
|
||||||
|
{
|
||||||
|
Folder::create($productDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$urls = [];
|
||||||
|
|
||||||
|
foreach ($docs as $doc)
|
||||||
|
{
|
||||||
|
$filename = $doc['name'] ?? basename($doc['relativename'] ?? '');
|
||||||
|
|
||||||
|
if (!preg_match('/\.(jpe?g|png|gif|webp)$/i', $filename))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the file content
|
||||||
|
$content = null;
|
||||||
|
|
||||||
|
if (!empty($doc['content']))
|
||||||
|
{
|
||||||
|
// Base64 encoded content
|
||||||
|
$content = base64_decode($doc['content']);
|
||||||
|
}
|
||||||
|
elseif (!empty($doc['fullname']))
|
||||||
|
{
|
||||||
|
// Fetch via documents/download endpoint
|
||||||
|
$download = $this->client->get('/documents/download', [
|
||||||
|
'modulepart' => 'product',
|
||||||
|
'original_file' => $doc['relativename'] ?? $filename,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($download['content']))
|
||||||
|
{
|
||||||
|
$content = base64_decode($download['content']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($content !== null)
|
||||||
|
{
|
||||||
|
$localPath = $productDir . '/' . $filename;
|
||||||
|
File::write($localPath, $content);
|
||||||
|
$urls[] = $this->baseUrl . '/' . $productId . '/' . $filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $urls;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Table\OrderTable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates orders and invoices in Dolibarr from cart data.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class OrderService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param DolibarrClient|null $client Optional client override.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct(?DolibarrClient $client = null)
|
||||||
|
{
|
||||||
|
$this->client = $client ?? new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an order in Dolibarr from cart items.
|
||||||
|
*
|
||||||
|
* @param int $thirdpartyId Dolibarr thirdparty (customer) ID.
|
||||||
|
* @param array $cartItems Cart items array from CartModel::getItems().
|
||||||
|
* @param array $metadata Additional order metadata (note_public, note_private, etc.).
|
||||||
|
*
|
||||||
|
* @return array|null Array with order data, or null on failure.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function createOrder(int $thirdpartyId, array $cartItems, array $metadata = []): ?array
|
||||||
|
{
|
||||||
|
if (empty($cartItems))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build line items
|
||||||
|
$lines = [];
|
||||||
|
|
||||||
|
foreach ($cartItems as $item)
|
||||||
|
{
|
||||||
|
$lines[] = [
|
||||||
|
'fk_product' => (int) $item['dolibarr_product_id'],
|
||||||
|
'qty' => (int) $item['quantity'],
|
||||||
|
'subprice' => (float) $item['unit_price'],
|
||||||
|
'tva_tx' => (float) $item['tax_rate'],
|
||||||
|
'product_type' => 0,
|
||||||
|
'desc' => $item['product_label'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderData = [
|
||||||
|
'socid' => $thirdpartyId,
|
||||||
|
'date' => date('Y-m-d'),
|
||||||
|
'lines' => $lines,
|
||||||
|
'note_public' => $metadata['note_public'] ?? '',
|
||||||
|
'note_private' => $metadata['note_private'] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->client->post('/orders', $orderData);
|
||||||
|
|
||||||
|
if ($result === null)
|
||||||
|
{
|
||||||
|
Log::add('OrderService: Failed to create order for thirdparty ' . $thirdpartyId, Log::ERROR, 'com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderId = (int) $result;
|
||||||
|
|
||||||
|
// Fetch created order details
|
||||||
|
$order = $this->client->get('/orders/' . $orderId);
|
||||||
|
|
||||||
|
if ($order === null)
|
||||||
|
{
|
||||||
|
return ['id' => $orderId, 'ref' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate (set to status 1 = validated)
|
||||||
|
$this->client->post('/orders/' . $orderId . '/validate', []);
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invoice from a Dolibarr order.
|
||||||
|
*
|
||||||
|
* @param int $orderId Dolibarr order ID.
|
||||||
|
*
|
||||||
|
* @return array|null Invoice data, or null on failure.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function createInvoiceFromOrder(int $orderId): ?array
|
||||||
|
{
|
||||||
|
$invoiceData = [
|
||||||
|
'socid' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use createfromorder endpoint
|
||||||
|
$result = $this->client->post('/invoices/createfromorder/' . $orderId, $invoiceData);
|
||||||
|
|
||||||
|
if ($result === null)
|
||||||
|
{
|
||||||
|
// Fallback: create invoice manually from order data
|
||||||
|
$order = $this->client->get('/orders/' . $orderId);
|
||||||
|
|
||||||
|
if ($order === null)
|
||||||
|
{
|
||||||
|
Log::add('OrderService: Failed to create invoice from order ' . $orderId, Log::ERROR, 'com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
|
||||||
|
foreach ($order['lines'] ?? [] as $line)
|
||||||
|
{
|
||||||
|
$lines[] = [
|
||||||
|
'fk_product' => (int) ($line['fk_product'] ?? 0),
|
||||||
|
'qty' => (float) ($line['qty'] ?? 1),
|
||||||
|
'subprice' => (float) ($line['subprice'] ?? 0),
|
||||||
|
'tva_tx' => (float) ($line['tva_tx'] ?? 0),
|
||||||
|
'product_type' => (int) ($line['product_type'] ?? 0),
|
||||||
|
'desc' => $line['desc'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoicePayload = [
|
||||||
|
'socid' => (int) ($order['socid'] ?? 0),
|
||||||
|
'date' => date('Y-m-d'),
|
||||||
|
'lines' => $lines,
|
||||||
|
'linked_objects' => ['commande' => $orderId],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $this->client->post('/invoices', $invoicePayload);
|
||||||
|
|
||||||
|
if ($result === null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceId = (int) $result;
|
||||||
|
$this->client->post('/invoices/' . $invoiceId . '/validate', []);
|
||||||
|
|
||||||
|
return $this->client->get('/invoices/' . $invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save order mapping to local database.
|
||||||
|
*
|
||||||
|
* @param int $userId Joomla user ID (0 for guest).
|
||||||
|
* @param int $orderId Dolibarr order ID.
|
||||||
|
* @param int $invoiceId Dolibarr invoice ID.
|
||||||
|
* @param int $thirdpartyId Dolibarr thirdparty ID.
|
||||||
|
* @param string $orderRef Order reference string.
|
||||||
|
* @param string $invoiceRef Invoice reference string.
|
||||||
|
* @param float $totalHT Total excl. tax.
|
||||||
|
* @param float $totalTTC Total incl. tax.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function saveOrderMapping(
|
||||||
|
int $userId,
|
||||||
|
int $orderId,
|
||||||
|
int $invoiceId,
|
||||||
|
int $thirdpartyId,
|
||||||
|
string $orderRef,
|
||||||
|
string $invoiceRef,
|
||||||
|
float $totalHT,
|
||||||
|
float $totalTTC
|
||||||
|
): bool {
|
||||||
|
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||||
|
$table = new OrderTable($db);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'dolibarr_order_id' => $orderId,
|
||||||
|
'dolibarr_invoice_id' => $invoiceId,
|
||||||
|
'dolibarr_thirdparty_id' => $thirdpartyId,
|
||||||
|
'order_ref' => $orderRef,
|
||||||
|
'invoice_ref' => $invoiceRef,
|
||||||
|
'total_ht' => $totalHT,
|
||||||
|
'total_ttc' => $totalTTC,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
];
|
||||||
|
|
||||||
|
$table->bind($data);
|
||||||
|
|
||||||
|
if (!$table->check())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $table->store();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook service — receives and processes Dolibarr webhook events.
|
||||||
|
*
|
||||||
|
* Endpoint: /api/mokodolijoomshop/webhook
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class WebhookService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate the webhook secret.
|
||||||
|
*
|
||||||
|
* @param string $providedSecret Secret from request header.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function validateSecret(string $providedSecret): bool
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$expectedSecret = $params->get('webhook_secret', '');
|
||||||
|
|
||||||
|
if (empty($expectedSecret))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($expectedSecret, $providedSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming webhook event.
|
||||||
|
*
|
||||||
|
* @param string $eventType Event type (e.g., 'PRODUCT_CREATE', 'ORDER_UPDATE').
|
||||||
|
* @param array $payload Event payload data.
|
||||||
|
*
|
||||||
|
* @return bool True if processed successfully.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function processEvent(string $eventType, array $payload): bool
|
||||||
|
{
|
||||||
|
$this->logEvent($eventType, $payload, 'processing');
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch ($eventType)
|
||||||
|
{
|
||||||
|
case 'PRODUCT_CREATE':
|
||||||
|
case 'PRODUCT_MODIFY':
|
||||||
|
$this->handleProductChange($payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PRODUCT_DELETE':
|
||||||
|
$this->handleProductDelete($payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ORDER_VALIDATE':
|
||||||
|
case 'ORDER_MODIFY':
|
||||||
|
case 'ORDER_CLOSE':
|
||||||
|
case 'ORDER_CANCEL':
|
||||||
|
$this->handleOrderStatusChange($payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PAYMENT_CUSTOMER_CREATE':
|
||||||
|
$this->handlePaymentReceived($payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$this->logEvent($eventType, $payload, 'ignored', 'Unknown event type');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logEvent($eventType, $payload, 'success');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (\Exception $e)
|
||||||
|
{
|
||||||
|
$this->logEvent($eventType, $payload, 'error', $e->getMessage());
|
||||||
|
Log::add('WebhookService: ' . $e->getMessage(), Log::ERROR, 'com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle product create/modify — invalidate image cache.
|
||||||
|
*
|
||||||
|
* @param array $payload Event payload.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function handleProductChange(array $payload): void
|
||||||
|
{
|
||||||
|
$productId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($productId > 0)
|
||||||
|
{
|
||||||
|
// Invalidate cached images for this product
|
||||||
|
$imageService = new ImageService();
|
||||||
|
$imageService->invalidateCache($productId);
|
||||||
|
|
||||||
|
// Clear API cache
|
||||||
|
CacheService::forget('products_list');
|
||||||
|
CacheService::forget('product_' . $productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle product deletion.
|
||||||
|
*
|
||||||
|
* @param array $payload Event payload.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function handleProductDelete(array $payload): void
|
||||||
|
{
|
||||||
|
$productId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($productId > 0)
|
||||||
|
{
|
||||||
|
$imageService = new ImageService();
|
||||||
|
$imageService->invalidateCache($productId);
|
||||||
|
CacheService::flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle order status changes — update local mapping.
|
||||||
|
*
|
||||||
|
* @param array $payload Event payload.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function handleOrderStatusChange(array $payload): void
|
||||||
|
{
|
||||||
|
$orderId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($orderId <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusMap = [
|
||||||
|
-1 => 'cancelled',
|
||||||
|
0 => 'draft',
|
||||||
|
1 => 'validated',
|
||||||
|
2 => 'shipped',
|
||||||
|
3 => 'delivered',
|
||||||
|
];
|
||||||
|
|
||||||
|
$statusCode = (int) ($payload['object_status'] ?? $payload['status'] ?? 0);
|
||||||
|
$newStatus = $statusMap[$statusCode] ?? 'unknown';
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->set($db->quoteName('status') . ' = ' . $db->quote($newStatus))
|
||||||
|
->where($db->quoteName('dolibarr_order_id') . ' = ' . $orderId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$db->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment received — update order status to paid.
|
||||||
|
*
|
||||||
|
* @param array $payload Event payload.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function handlePaymentReceived(array $payload): void
|
||||||
|
{
|
||||||
|
$invoiceId = (int) ($payload['object_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($invoiceId <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->set($db->quoteName('status') . ' = ' . $db->quote('paid'))
|
||||||
|
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$db->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a webhook event to the database.
|
||||||
|
*
|
||||||
|
* @param string $eventType Event type.
|
||||||
|
* @param array $payload Payload data.
|
||||||
|
* @param string $status Processing status.
|
||||||
|
* @param string $message Optional message.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function logEvent(string $eventType, array $payload, string $status, string $message = ''): void
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->insert($db->quoteName('#__mokodolijoomshop_webhook_log'))
|
||||||
|
->columns(['event_type', 'payload', 'status', 'message'])
|
||||||
|
->values(implode(',', [
|
||||||
|
$db->quote($eventType),
|
||||||
|
$db->quote(json_encode($payload)),
|
||||||
|
$db->quote($status),
|
||||||
|
$db->quote(mb_substr($message, 0, 500)),
|
||||||
|
]));
|
||||||
|
$db->setQuery($query);
|
||||||
|
$db->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Table\Table;
|
||||||
|
use Joomla\Database\DatabaseDriver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart item table class.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CartTable extends Table
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param DatabaseDriver $db Database connector.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct(DatabaseDriver $db)
|
||||||
|
{
|
||||||
|
parent::__construct('#__mokodolijoomshop_cart', 'id', $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation before store.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function check(): bool
|
||||||
|
{
|
||||||
|
if (empty($this->session_id) && empty($this->user_id))
|
||||||
|
{
|
||||||
|
$this->setError('Cart item must have a session_id or user_id.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->dolibarr_product_id))
|
||||||
|
{
|
||||||
|
$this->setError('Cart item must have a product ID.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->quantity < 1)
|
||||||
|
{
|
||||||
|
$this->quantity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Table\Table;
|
||||||
|
use Joomla\Database\DatabaseDriver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer mapping table class.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CustomerTable extends Table
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param DatabaseDriver $db Database connector.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct(DatabaseDriver $db)
|
||||||
|
{
|
||||||
|
parent::__construct('#__mokodolijoomshop_customers', 'id', $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation before store.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function check(): bool
|
||||||
|
{
|
||||||
|
if (empty($this->user_id))
|
||||||
|
{
|
||||||
|
$this->setError('Customer mapping must have a Joomla user_id.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->dolibarr_thirdparty_id))
|
||||||
|
{
|
||||||
|
$this->setError('Customer mapping must have a Dolibarr thirdparty_id.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Table\Table;
|
||||||
|
use Joomla\Database\DatabaseDriver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order mapping table class.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class OrderTable extends Table
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param DatabaseDriver $db Database connector.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct(DatabaseDriver $db)
|
||||||
|
{
|
||||||
|
parent::__construct('#__mokodolijoomshop_orders', 'id', $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation before store.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function check(): bool
|
||||||
|
{
|
||||||
|
if (empty($this->dolibarr_order_id))
|
||||||
|
{
|
||||||
|
$this->setError('Order mapping must have a Dolibarr order ID.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ namespace Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard;
|
|||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Model\DashboardModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard view for the admin.
|
* Dashboard view for the admin.
|
||||||
@@ -27,6 +29,48 @@ class HtmlView extends BaseHtmlView
|
|||||||
*/
|
*/
|
||||||
protected bool $connectionOk = false;
|
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.
|
* Display the dashboard.
|
||||||
*
|
*
|
||||||
@@ -39,7 +83,21 @@ class HtmlView extends BaseHtmlView
|
|||||||
public function display($tpl = null): void
|
public function display($tpl = null): void
|
||||||
{
|
{
|
||||||
$client = new DolibarrClient();
|
$client = new DolibarrClient();
|
||||||
$this->connectionOk = $client->testConnection();
|
$this->connectionStatus = $client->testConnectionDetailed();
|
||||||
|
$this->connectionOk = $this->connectionStatus['ok'];
|
||||||
|
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$this->currency = $params->get('currency', 'USD');
|
||||||
|
|
||||||
|
if ($this->connectionOk)
|
||||||
|
{
|
||||||
|
$dashModel = new DashboardModel();
|
||||||
|
$this->productCount = $dashModel->getProductCount();
|
||||||
|
$this->orderCount = $dashModel->getOrderCount();
|
||||||
|
$this->customerCount = $dashModel->getCustomerCount();
|
||||||
|
$this->recentOrders = $dashModel->getRecentOrders(5);
|
||||||
|
$this->revenue = $dashModel->getRevenue();
|
||||||
|
}
|
||||||
|
|
||||||
ToolbarHelper::title('DoliJoom Shop: Dashboard');
|
ToolbarHelper::title('DoliJoom Shop: Dashboard');
|
||||||
ToolbarHelper::preferences('com_mokodolijoomshop');
|
ToolbarHelper::preferences('com_mokodolijoomshop');
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Administrator\View\Orders;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin orders list view.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array Order items.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $items = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Currency.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected string $currency = 'USD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the orders list.
|
||||||
|
*
|
||||||
|
* @param string $tpl Template name.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function display($tpl = null): void
|
||||||
|
{
|
||||||
|
$model = $this->getModel();
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$this->items = $model->getItems();
|
||||||
|
$this->currency = $params->get('currency', 'USD');
|
||||||
|
|
||||||
|
ToolbarHelper::title('DoliJoom Shop: Orders');
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,49 +9,177 @@
|
|||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard\HtmlView $this */
|
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard\HtmlView $this */
|
||||||
|
|
||||||
|
$status = $this->connectionStatus;
|
||||||
|
$currency = htmlspecialchars($this->currency);
|
||||||
?>
|
?>
|
||||||
<div class="com-mokodolijoomshop-dashboard">
|
<div class="com-mokodolijoomshop-dashboard">
|
||||||
<div class="row">
|
<!-- Connection Status -->
|
||||||
|
<div class="row mb-4">
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card mb-3">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_FIELDSET_DOLIBARR'); ?></h3>
|
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_FIELDSET_DOLIBARR'); ?></h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<?php if ($this->connectionOk) : ?>
|
<?php if ($this->connectionOk) : ?>
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success mb-2">
|
||||||
<span class="icon-check" aria-hidden="true"></span>
|
<span class="icon-check" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_OK'); ?>
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_OK'); ?>
|
||||||
</div>
|
</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'); ?>
|
||||||
|
|
||||||
|
<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 : ?>
|
<?php else : ?>
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
<span class="icon-warning" aria-hidden="true"></span>
|
<span class="icon-warning" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED'); ?>
|
<?php echo htmlspecialchars($status['error'] ?: Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED')); ?>
|
||||||
</div>
|
</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; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card mb-3">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Quick Actions</h3>
|
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUICK_ACTIONS'); ?></h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<a href="index.php?option=com_mokodolijoomshop&view=products" class="btn btn-primary mb-2 d-block">
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary mb-2 d-block">
|
||||||
|
<span class="icon-cube" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||||
</a>
|
</a>
|
||||||
<a href="index.php?option=com_mokodolijoomshop&view=orders" class="btn btn-outline-primary mb-2 d-block">
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" class="btn btn-outline-primary mb-2 d-block">
|
||||||
|
<span class="icon-cart" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?>
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?>
|
||||||
</a>
|
</a>
|
||||||
<a href="index.php?option=com_mokodolijoomshop&view=customers" class="btn btn-outline-primary mb-2 d-block">
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=customers'); ?>" class="btn btn-outline-primary mb-2 d-block">
|
||||||
|
<span class="icon-users" aria-hidden="true"></span>
|
||||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMERS'); ?>
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMERS'); ?>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\HTML\HTMLHelper;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Orders\HtmlView $this */
|
||||||
|
|
||||||
|
$currency = htmlspecialchars($this->currency);
|
||||||
|
?>
|
||||||
|
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" method="get" id="adminForm" name="adminForm">
|
||||||
|
<input type="hidden" name="option" value="com_mokodolijoomshop" />
|
||||||
|
<input type="hidden" name="view" value="orders" />
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" name="filter_search" class="form-control" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_SEARCH'); ?>"
|
||||||
|
value="<?php echo htmlspecialchars(\Joomla\CMS\Factory::getApplication()->input->getString('filter_search', '')); ?>" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="filter_status" class="form-select">
|
||||||
|
<option value=""><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<option value="validated">Validated</option>
|
||||||
|
<option value="shipped">Shipped</option>
|
||||||
|
<option value="delivered">Delivered</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="date" name="filter_date_from" class="form-control" placeholder="From" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="date" name="filter_date_to" class="form-control" placeholder="To" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<span class="icon-search" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($this->items)) : ?>
|
||||||
|
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="table table-striped" id="orderList">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_HT'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($this->items as $order) : ?>
|
||||||
|
<?php
|
||||||
|
$statusClass = match ($order['status'] ?? '') {
|
||||||
|
'confirmed', 'validated' => 'bg-success',
|
||||||
|
'shipped' => 'bg-info',
|
||||||
|
'delivered' => 'bg-primary',
|
||||||
|
'cancelled' => 'bg-danger',
|
||||||
|
default => 'bg-secondary',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo htmlspecialchars($order['created']); ?></td>
|
||||||
|
<td><?php echo htmlspecialchars($order['customer_name'] ?? ''); ?></td>
|
||||||
|
<td><?php echo htmlspecialchars($order['order_ref']); ?></td>
|
||||||
|
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format((float) $order['total_ht'], 2); ?> <?php echo $currency; ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format((float) $order['total_ttc'], 2); ?> <?php echo $currency; ?></td>
|
||||||
|
<td><span class="badge <?php echo $statusClass; ?>"><?php echo htmlspecialchars($order['status']); ?></span></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
@@ -37,8 +37,14 @@
|
|||||||
</sql>
|
</sql>
|
||||||
</uninstall>
|
</uninstall>
|
||||||
|
|
||||||
|
<media destination="com_mokodolijoomshop" folder="../media/com_mokodolijoomshop">
|
||||||
|
<folder>css</folder>
|
||||||
|
<folder>images</folder>
|
||||||
|
</media>
|
||||||
|
|
||||||
<files folder="site">
|
<files folder="site">
|
||||||
<folder>language</folder>
|
<folder>language</folder>
|
||||||
|
<folder>services</folder>
|
||||||
<folder>src</folder>
|
<folder>src</folder>
|
||||||
<folder>tmpl</folder>
|
<folder>tmpl</folder>
|
||||||
</files>
|
</files>
|
||||||
@@ -127,11 +133,79 @@
|
|||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
</field>
|
</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>
|
</fieldset>
|
||||||
</fields>
|
</fields>
|
||||||
</config>
|
</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>
|
<updateservers>
|
||||||
<server type="extension" name="MokoDoliJoomShop Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoDoliJoomShop/raw/branch/main/updates.xml</server>
|
<server type="extension" name="MokoDoliJoomShop Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoDoliJoomShop/updates.xml</server>
|
||||||
</updateservers>
|
</updateservers>
|
||||||
</extension>
|
</extension>
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,106 @@ COM_MOKODOLIJOOMSHOP_CHECKOUT="Checkout"
|
|||||||
COM_MOKODOLIJOOMSHOP_ADD_TO_CART="Add to Cart"
|
COM_MOKODOLIJOOMSHOP_ADD_TO_CART="Add to Cart"
|
||||||
COM_MOKODOLIJOOMSHOP_VIEW_CART="View Cart"
|
COM_MOKODOLIJOOMSHOP_VIEW_CART="View Cart"
|
||||||
COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT="Proceed to Checkout"
|
COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT="Proceed to Checkout"
|
||||||
|
COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING="Continue Shopping"
|
||||||
COM_MOKODOLIJOOMSHOP_CART_EMPTY="Your cart is empty."
|
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_ORDER_PLACED="Your order has been placed successfully."
|
||||||
COM_MOKODOLIJOOMSHOP_PRICE="Price"
|
COM_MOKODOLIJOOMSHOP_PRICE="Price"
|
||||||
|
COM_MOKODOLIJOOMSHOP_PRICE_HT="Price (excl. tax)"
|
||||||
COM_MOKODOLIJOOMSHOP_QUANTITY="Quantity"
|
COM_MOKODOLIJOOMSHOP_QUANTITY="Quantity"
|
||||||
COM_MOKODOLIJOOMSHOP_SUBTOTAL="Subtotal"
|
COM_MOKODOLIJOOMSHOP_SUBTOTAL="Subtotal"
|
||||||
COM_MOKODOLIJOOMSHOP_TAX="Tax"
|
COM_MOKODOLIJOOMSHOP_TAX="Tax"
|
||||||
COM_MOKODOLIJOOMSHOP_TOTAL="Total"
|
COM_MOKODOLIJOOMSHOP_TOTAL="Total"
|
||||||
COM_MOKODOLIJOOMSHOP_IN_STOCK="In Stock"
|
COM_MOKODOLIJOOMSHOP_IN_STOCK="In Stock"
|
||||||
COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK="Out of 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"
|
||||||
|
|||||||
@@ -8,4 +8,5 @@
|
|||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
// Site service provider — component registration handled by admin provider
|
// Site service provider — component registration is handled by the admin provider.
|
||||||
|
// This file must exist but no additional services are needed for the site side.
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\MVC\Controller\BaseController;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart controller — handles add, update, and remove actions.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CartController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add a product to the cart.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function add(): void
|
||||||
|
{
|
||||||
|
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
$productId = $this->input->getInt('product_id', 0);
|
||||||
|
$quantity = $this->input->getInt('quantity', 1);
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||||
|
$model = $this->getModel('Cart');
|
||||||
|
|
||||||
|
if ($model->addItem($productId, $quantity))
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ITEM_ADDED'), 'success');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ADD_FAILED'), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = $this->input->getBase64('return', '');
|
||||||
|
|
||||||
|
if ($return)
|
||||||
|
{
|
||||||
|
$this->setRedirect(base64_decode($return));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cart item quantity.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
$cartItemId = $this->input->getInt('cart_item_id', 0);
|
||||||
|
$quantity = $this->input->getInt('quantity', 1);
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||||
|
$model = $this->getModel('Cart');
|
||||||
|
$model->updateItemQuantity($cartItemId, $quantity);
|
||||||
|
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a cart item.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function remove(): void
|
||||||
|
{
|
||||||
|
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
$cartItemId = $this->input->getInt('cart_item_id', 0);
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||||
|
$model = $this->getModel('Cart');
|
||||||
|
$model->removeItem($cartItemId);
|
||||||
|
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ITEM_REMOVED'), 'success');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\MVC\Controller\BaseController;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout controller — processes the checkout form submission.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CheckoutController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Process the checkout form.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function process(): void
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CheckoutModel $checkoutModel */
|
||||||
|
$checkoutModel = $this->getModel('Checkout');
|
||||||
|
|
||||||
|
if (!$checkoutModel->canCheckout())
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED'), 'warning');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_users&view=login', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $cartModel */
|
||||||
|
$cartModel = $this->getModel('Cart');
|
||||||
|
$cartItems = $cartModel->getItems();
|
||||||
|
|
||||||
|
if (empty($cartItems))
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'), 'warning');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate stock before proceeding
|
||||||
|
$stockProblems = $cartModel->validateStock();
|
||||||
|
|
||||||
|
if (!empty($stockProblems))
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_STOCK_ERROR'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect billing data from form
|
||||||
|
$billingData = [
|
||||||
|
'name' => $this->input->getString('billing_name', ''),
|
||||||
|
'email' => $this->input->getString('billing_email', ''),
|
||||||
|
'address' => $this->input->getString('billing_address', ''),
|
||||||
|
'town' => $this->input->getString('billing_town', ''),
|
||||||
|
'zip' => $this->input->getString('billing_zip', ''),
|
||||||
|
'phone' => $this->input->getString('billing_phone', ''),
|
||||||
|
'notes' => $this->input->getString('order_notes', ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
$totals = $cartModel->getTotals();
|
||||||
|
$result = $checkoutModel->processCheckout($billingData, $cartItems, $totals);
|
||||||
|
|
||||||
|
if ($result === null)
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_FAILED'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=checkout', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the cart on success
|
||||||
|
$cartModel->clearCart();
|
||||||
|
|
||||||
|
// Store result in session for confirmation page
|
||||||
|
$session = $this->app->getSession();
|
||||||
|
$session->set('mokodolijoomshop.order_result', $result);
|
||||||
|
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_ORDER_PLACED'), 'success');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=checkout&layout=confirmation', false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\MVC\Controller\BaseController;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice controller — handles PDF download for frontend users.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class InvoiceController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Download an invoice PDF.
|
||||||
|
*
|
||||||
|
* Streams the PDF directly from Dolibarr to the browser.
|
||||||
|
* Access is restricted to the order owner.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function download(): void
|
||||||
|
{
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
$invoiceId = $this->input->getInt('invoice_id', 0);
|
||||||
|
|
||||||
|
if ($userId === 0 || $invoiceId === 0)
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
|
||||||
|
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select($db->quoteName('invoice_ref'))
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||||
|
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$invoiceRef = $db->loadResult();
|
||||||
|
|
||||||
|
if ($invoiceRef === null)
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
|
||||||
|
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch PDF from Dolibarr
|
||||||
|
$client = new DolibarrClient();
|
||||||
|
$docs = $client->get('/documents', [
|
||||||
|
'modulepart' => 'invoice',
|
||||||
|
'id' => $invoiceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdfDoc = null;
|
||||||
|
|
||||||
|
if (!empty($docs))
|
||||||
|
{
|
||||||
|
foreach ($docs as $doc)
|
||||||
|
{
|
||||||
|
if (isset($doc['relativename']) && str_ends_with($doc['relativename'], '.pdf'))
|
||||||
|
{
|
||||||
|
$pdfDoc = $doc;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pdfDoc === null)
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND'), 'warning');
|
||||||
|
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download content
|
||||||
|
$download = $client->get('/documents/download', [
|
||||||
|
'modulepart' => 'invoice',
|
||||||
|
'original_file' => $pdfDoc['relativename'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (empty($download['content']))
|
||||||
|
{
|
||||||
|
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND'), 'warning');
|
||||||
|
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdfContent = base64_decode($download['content']);
|
||||||
|
$filename = $invoiceRef . '.pdf';
|
||||||
|
|
||||||
|
// Stream PDF to browser
|
||||||
|
$this->app->setHeader('Content-Type', 'application/pdf');
|
||||||
|
$this->app->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||||
|
$this->app->setHeader('Content-Length', (string) \strlen($pdfContent));
|
||||||
|
$this->app->sendHeaders();
|
||||||
|
|
||||||
|
echo $pdfContent;
|
||||||
|
$this->app->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\MVC\Controller\BaseController;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search controller — provides AJAX product search and filtering.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class SearchController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* AJAX search endpoint.
|
||||||
|
*
|
||||||
|
* Accepts: q (text), category_id, price_min, price_max, sort, page.
|
||||||
|
* Returns JSON array of products.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function search(): void
|
||||||
|
{
|
||||||
|
$client = new DolibarrClient();
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$perPage = (int) $params->get('products_per_page', 12);
|
||||||
|
|
||||||
|
$q = $this->input->getString('q', '');
|
||||||
|
$categoryId = $this->input->getInt('category_id', 0);
|
||||||
|
$priceMin = $this->input->getFloat('price_min', 0);
|
||||||
|
$priceMax = $this->input->getFloat('price_max', 0);
|
||||||
|
$sort = $this->input->getString('sort', 'ref_asc');
|
||||||
|
$page = $this->input->getInt('page', 0);
|
||||||
|
|
||||||
|
// Build sort parameters
|
||||||
|
$sortMap = [
|
||||||
|
'ref_asc' => ['t.ref', 'ASC'],
|
||||||
|
'ref_desc' => ['t.ref', 'DESC'],
|
||||||
|
'label_asc' => ['t.label', 'ASC'],
|
||||||
|
'label_desc' => ['t.label', 'DESC'],
|
||||||
|
'price_asc' => ['t.price', 'ASC'],
|
||||||
|
'price_desc' => ['t.price', 'DESC'],
|
||||||
|
'newest' => ['t.datec', 'DESC'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$sortField = $sortMap[$sort][0] ?? 't.ref';
|
||||||
|
$sortOrder = $sortMap[$sort][1] ?? 'ASC';
|
||||||
|
|
||||||
|
$query = [
|
||||||
|
'sortfield' => $sortField,
|
||||||
|
'sortorder' => $sortOrder,
|
||||||
|
'limit' => $perPage,
|
||||||
|
'page' => $page,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($categoryId > 0)
|
||||||
|
{
|
||||||
|
$query['category'] = $categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sqlfilters for text search and price range
|
||||||
|
$filters = [];
|
||||||
|
|
||||||
|
if (!empty($q))
|
||||||
|
{
|
||||||
|
$escaped = addslashes($q);
|
||||||
|
$filters[] = "(t.label:like:'%{$escaped}%') or (t.ref:like:'%{$escaped}%') or (t.description:like:'%{$escaped}%')";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priceMin > 0)
|
||||||
|
{
|
||||||
|
$filters[] = "(t.price:>=:{$priceMin})";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priceMax > 0)
|
||||||
|
{
|
||||||
|
$filters[] = "(t.price:<=:{$priceMax})";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters))
|
||||||
|
{
|
||||||
|
$query['sqlfilters'] = implode(' and ', $filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = $client->get('/products', $query);
|
||||||
|
|
||||||
|
// Return JSON response
|
||||||
|
$this->app->setHeader('Content-Type', 'application/json');
|
||||||
|
$this->app->sendHeaders();
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'products' => $products ?? [],
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->app->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coupon/discount code helper — validates codes against Dolibarr discount rules.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CouponHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->client = new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a coupon code and return the discount details.
|
||||||
|
*
|
||||||
|
* @param string $code Coupon code entered by user.
|
||||||
|
* @param int $thirdpartyId Customer thirdparty ID (for customer-specific discounts).
|
||||||
|
*
|
||||||
|
* @return array|null Discount data or null if invalid.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function validate(string $code, int $thirdpartyId = 0): ?array
|
||||||
|
{
|
||||||
|
if (empty(trim($code)))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for discount rules matching this code in Dolibarr
|
||||||
|
// Dolibarr stores available discounts per thirdparty
|
||||||
|
if ($thirdpartyId > 0)
|
||||||
|
{
|
||||||
|
$discounts = $this->client->get('/thirdparties/' . $thirdpartyId . '/availablediscounts');
|
||||||
|
|
||||||
|
if (!empty($discounts))
|
||||||
|
{
|
||||||
|
foreach ($discounts as $discount)
|
||||||
|
{
|
||||||
|
if (($discount['description'] ?? '') === $code || ($discount['ref'] ?? '') === $code)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) ($discount['id'] ?? 0),
|
||||||
|
'type' => !empty($discount['percent']) ? 'percent' : 'fixed',
|
||||||
|
'value' => (float) ($discount['percent'] ?? $discount['amount_ttc'] ?? 0),
|
||||||
|
'amount_ht' => (float) ($discount['amount_ht'] ?? 0),
|
||||||
|
'amount_ttc' => (float) ($discount['amount_ttc'] ?? 0),
|
||||||
|
'description' => $discount['description'] ?? $code,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a discount to a cart total.
|
||||||
|
*
|
||||||
|
* @param array $discount Discount data from validate().
|
||||||
|
* @param float $subtotal Cart subtotal before discount.
|
||||||
|
*
|
||||||
|
* @return float Discount amount to subtract.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function calculateDiscount(array $discount, float $subtotal): float
|
||||||
|
{
|
||||||
|
if ($discount['type'] === 'percent')
|
||||||
|
{
|
||||||
|
return round($subtotal * ($discount['value'] / 100), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed amount — don't exceed subtotal
|
||||||
|
return min($discount['amount_ttc'], $subtotal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stock display helper — determines stock status and badge rendering.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class StockHelper
|
||||||
|
{
|
||||||
|
public const STATUS_IN_STOCK = 'in_stock';
|
||||||
|
public const STATUS_LOW_STOCK = 'low_stock';
|
||||||
|
public const STATUS_OUT = 'out_of_stock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine stock status for a given quantity.
|
||||||
|
*
|
||||||
|
* @param float $stockQty Stock quantity.
|
||||||
|
*
|
||||||
|
* @return string One of the STATUS_ constants.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function getStatus(float $stockQty): string
|
||||||
|
{
|
||||||
|
if ($stockQty <= 0)
|
||||||
|
{
|
||||||
|
return self::STATUS_OUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$threshold = (int) $params->get('low_stock_threshold', 5);
|
||||||
|
|
||||||
|
if ($stockQty <= $threshold)
|
||||||
|
{
|
||||||
|
return self::STATUS_LOW_STOCK;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STATUS_IN_STOCK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a Bootstrap badge for stock status.
|
||||||
|
*
|
||||||
|
* @param float $stockQty Stock quantity.
|
||||||
|
* @param bool $showQty Whether to show the numeric quantity.
|
||||||
|
*
|
||||||
|
* @return string HTML badge markup.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function renderBadge(float $stockQty, bool $showQty = false): string
|
||||||
|
{
|
||||||
|
$status = self::getStatus($stockQty);
|
||||||
|
|
||||||
|
switch ($status)
|
||||||
|
{
|
||||||
|
case self::STATUS_IN_STOCK:
|
||||||
|
$class = 'bg-success';
|
||||||
|
$text = Text::_('COM_MOKODOLIJOOMSHOP_IN_STOCK');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case self::STATUS_LOW_STOCK:
|
||||||
|
$class = 'bg-warning text-dark';
|
||||||
|
$text = Text::_('COM_MOKODOLIJOOMSHOP_LOW_STOCK');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case self::STATUS_OUT:
|
||||||
|
default:
|
||||||
|
$class = 'bg-danger';
|
||||||
|
$text = Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($showQty && $stockQty > 0)
|
||||||
|
{
|
||||||
|
$text .= ' (' . (int) $stockQty . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<span class="badge ' . $class . '">' . $text . '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if add-to-cart should be enabled.
|
||||||
|
*
|
||||||
|
* @param float $stockQty Stock quantity.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function canAddToCart(float $stockQty): bool
|
||||||
|
{
|
||||||
|
if ($stockQty > 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if backorders are allowed
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return (bool) $params->get('allow_backorder', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tax calculation and display helper.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class TaxHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the configured tax display mode.
|
||||||
|
*
|
||||||
|
* @return string 'ttc' (incl. tax), 'ht' (excl. tax), or 'both'.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function getDisplayMode(): string
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return $params->get('tax_display', 'ttc');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tax is enabled.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function isEnabled(): bool
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return (bool) $params->get('tax_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate tax breakdown grouped by rate from cart items.
|
||||||
|
*
|
||||||
|
* @param array $cartItems Cart items with 'unit_price', 'quantity', 'tax_rate'.
|
||||||
|
*
|
||||||
|
* @return array Array of [rate => amount], e.g., [20.0 => 40.00, 5.0 => 2.50].
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function getGroupedTax(array $cartItems): array
|
||||||
|
{
|
||||||
|
$grouped = [];
|
||||||
|
|
||||||
|
foreach ($cartItems as $item)
|
||||||
|
{
|
||||||
|
$rate = (float) ($item['tax_rate'] ?? 0);
|
||||||
|
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||||
|
$taxAmount = $lineTotal * ($rate / 100);
|
||||||
|
|
||||||
|
if ($rate > 0)
|
||||||
|
{
|
||||||
|
if (!isset($grouped[$rate]))
|
||||||
|
{
|
||||||
|
$grouped[$rate] = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grouped[$rate] += $taxAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($grouped);
|
||||||
|
|
||||||
|
return $grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price for display based on the tax display mode.
|
||||||
|
*
|
||||||
|
* @param float $priceHT Price excluding tax.
|
||||||
|
* @param float $priceTTC Price including tax.
|
||||||
|
* @param string $currency Currency code.
|
||||||
|
*
|
||||||
|
* @return string Formatted price string.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function formatPrice(float $priceHT, float $priceTTC, string $currency): string
|
||||||
|
{
|
||||||
|
$mode = self::getDisplayMode();
|
||||||
|
|
||||||
|
switch ($mode)
|
||||||
|
{
|
||||||
|
case 'ht':
|
||||||
|
return number_format($priceHT, 2) . ' ' . $currency . ' HT';
|
||||||
|
|
||||||
|
case 'both':
|
||||||
|
return number_format($priceTTC, 2) . ' ' . $currency
|
||||||
|
. ' <small class="text-muted">(' . number_format($priceHT, 2) . ' HT)</small>';
|
||||||
|
|
||||||
|
case 'ttc':
|
||||||
|
default:
|
||||||
|
return number_format($priceTTC, 2) . ' ' . $currency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate totals from cart items including tax breakdown.
|
||||||
|
*
|
||||||
|
* @param array $cartItems Cart items.
|
||||||
|
*
|
||||||
|
* @return array{subtotal_ht: float, tax_total: float, total_ttc: float, tax_grouped: array}
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public static function calculateTotals(array $cartItems): array
|
||||||
|
{
|
||||||
|
$subtotalHT = 0.0;
|
||||||
|
$taxTotal = 0.0;
|
||||||
|
$grouped = [];
|
||||||
|
|
||||||
|
foreach ($cartItems as $item)
|
||||||
|
{
|
||||||
|
$rate = (float) ($item['tax_rate'] ?? 0);
|
||||||
|
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||||
|
$lineTax = $lineTotal * ($rate / 100);
|
||||||
|
|
||||||
|
$subtotalHT += $lineTotal;
|
||||||
|
$taxTotal += $lineTax;
|
||||||
|
|
||||||
|
if ($rate > 0)
|
||||||
|
{
|
||||||
|
if (!isset($grouped[$rate]))
|
||||||
|
{
|
||||||
|
$grouped[$rate] = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grouped[$rate] += $lineTax;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($grouped);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subtotal_ht' => $subtotalHT,
|
||||||
|
'tax_total' => $taxTotal,
|
||||||
|
'total_ttc' => $subtotalHT + $taxTotal,
|
||||||
|
'tax_grouped' => $grouped,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product variant helper — handles Dolibarr product variants/combinations.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class VariantHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->client = new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get variants for a product.
|
||||||
|
*
|
||||||
|
* @param int $productId Parent product ID.
|
||||||
|
*
|
||||||
|
* @return array Array of variant data.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getVariants(int $productId): array
|
||||||
|
{
|
||||||
|
$variants = $this->client->get('/products/' . $productId . '/variants');
|
||||||
|
|
||||||
|
if ($variants === null || !\is_array($variants))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a product has variants.
|
||||||
|
*
|
||||||
|
* @param int $productId Product ID.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function hasVariants(int $productId): bool
|
||||||
|
{
|
||||||
|
$variants = $this->getVariants($productId);
|
||||||
|
|
||||||
|
return !empty($variants);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse variants into grouped attribute selectors.
|
||||||
|
*
|
||||||
|
* Returns a structure like:
|
||||||
|
* [
|
||||||
|
* 'Color' => ['Red' => [...], 'Blue' => [...]],
|
||||||
|
* 'Size' => ['S' => [...], 'M' => [...], 'L' => [...]],
|
||||||
|
* ]
|
||||||
|
*
|
||||||
|
* @param array $variants Raw variants from Dolibarr.
|
||||||
|
*
|
||||||
|
* @return array Grouped attributes.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function groupByAttribute(array $variants): array
|
||||||
|
{
|
||||||
|
$grouped = [];
|
||||||
|
|
||||||
|
foreach ($variants as $variant)
|
||||||
|
{
|
||||||
|
$attributes = $variant['attributes'] ?? [];
|
||||||
|
|
||||||
|
foreach ($attributes as $attr)
|
||||||
|
{
|
||||||
|
$attrName = $attr['attribute'] ?? $attr['ref'] ?? 'Option';
|
||||||
|
$attrValue = $attr['value'] ?? $attr['ref_ext'] ?? '';
|
||||||
|
|
||||||
|
if (!isset($grouped[$attrName]))
|
||||||
|
{
|
||||||
|
$grouped[$attrName] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($grouped[$attrName][$attrValue]))
|
||||||
|
{
|
||||||
|
$grouped[$attrName][$attrValue] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$grouped[$attrName][$attrValue][] = [
|
||||||
|
'variant_id' => (int) ($variant['fk_product_child'] ?? $variant['id'] ?? 0),
|
||||||
|
'ref' => $variant['ref'] ?? '',
|
||||||
|
'price_diff' => (float) ($variant['variation_price'] ?? 0),
|
||||||
|
'price_type' => $variant['variation_price_percentage'] ?? false,
|
||||||
|
'stock' => (float) ($variant['stock_reel'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build JSON data for variant selectors (consumed by frontend JS).
|
||||||
|
*
|
||||||
|
* @param int $productId Parent product ID.
|
||||||
|
* @param float $basePrice Base product price.
|
||||||
|
*
|
||||||
|
* @return array Variant config for JSON encoding.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getVariantConfig(int $productId, float $basePrice): array
|
||||||
|
{
|
||||||
|
$variants = $this->getVariants($productId);
|
||||||
|
|
||||||
|
if (empty($variants))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = [
|
||||||
|
'base_price' => $basePrice,
|
||||||
|
'variants' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($variants as $variant)
|
||||||
|
{
|
||||||
|
$childId = (int) ($variant['fk_product_child'] ?? $variant['id'] ?? 0);
|
||||||
|
$priceDiff = (float) ($variant['variation_price'] ?? 0);
|
||||||
|
$isPercent = !empty($variant['variation_price_percentage']);
|
||||||
|
$finalPrice = $isPercent
|
||||||
|
? $basePrice * (1 + $priceDiff / 100)
|
||||||
|
: $basePrice + $priceDiff;
|
||||||
|
|
||||||
|
$config['variants'][] = [
|
||||||
|
'id' => $childId,
|
||||||
|
'ref' => $variant['ref'] ?? '',
|
||||||
|
'attributes' => $variant['attributes'] ?? [],
|
||||||
|
'price' => round($finalPrice, 4),
|
||||||
|
'price_diff' => $priceDiff,
|
||||||
|
'stock' => (float) ($variant['stock_reel'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shipping address model — manages user address book.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class AddressModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all addresses for the current user.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getAddresses(): array
|
||||||
|
{
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
if ($userId === 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||||
|
->order($db->quoteName('is_default') . ' DESC, ' . $db->quoteName('label') . ' ASC');
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadAssocList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default address for the current user.
|
||||||
|
*
|
||||||
|
* @return array|null
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getDefaultAddress(): ?array
|
||||||
|
{
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
if ($userId === 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||||
|
->where($db->quoteName('is_default') . ' = 1');
|
||||||
|
|
||||||
|
$db->setQuery($query, 0, 1);
|
||||||
|
$result = $db->loadAssoc();
|
||||||
|
|
||||||
|
return $result ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an address.
|
||||||
|
*
|
||||||
|
* @param array $data Address data.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function saveAddress(array $data): bool
|
||||||
|
{
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
if ($userId === 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
// If setting as default, clear other defaults first
|
||||||
|
if (!empty($data['is_default']))
|
||||||
|
{
|
||||||
|
$clear = $db->getQuery(true);
|
||||||
|
$clear->update($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||||
|
->set($db->quoteName('is_default') . ' = 0')
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
$db->setQuery($clear);
|
||||||
|
$db->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($data['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($id > 0)
|
||||||
|
{
|
||||||
|
// Update existing
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->update($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||||
|
->set($db->quoteName('label') . ' = ' . $db->quote($data['label'] ?? ''))
|
||||||
|
->set($db->quoteName('name') . ' = ' . $db->quote($data['name'] ?? ''))
|
||||||
|
->set($db->quoteName('address') . ' = ' . $db->quote($data['address'] ?? ''))
|
||||||
|
->set($db->quoteName('town') . ' = ' . $db->quote($data['town'] ?? ''))
|
||||||
|
->set($db->quoteName('zip') . ' = ' . $db->quote($data['zip'] ?? ''))
|
||||||
|
->set($db->quoteName('country_code') . ' = ' . $db->quote($data['country_code'] ?? ''))
|
||||||
|
->set($db->quoteName('phone') . ' = ' . $db->quote($data['phone'] ?? ''))
|
||||||
|
->set($db->quoteName('is_default') . ' = ' . (int) ($data['is_default'] ?? 0))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $id)
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->execute() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->insert($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||||
|
->columns(['user_id', 'label', 'name', 'address', 'town', 'zip', 'country_code', 'phone', 'is_default'])
|
||||||
|
->values(implode(',', [
|
||||||
|
$userId,
|
||||||
|
$db->quote($data['label'] ?? ''),
|
||||||
|
$db->quote($data['name'] ?? ''),
|
||||||
|
$db->quote($data['address'] ?? ''),
|
||||||
|
$db->quote($data['town'] ?? ''),
|
||||||
|
$db->quote($data['zip'] ?? ''),
|
||||||
|
$db->quote($data['country_code'] ?? ''),
|
||||||
|
$db->quote($data['phone'] ?? ''),
|
||||||
|
(int) ($data['is_default'] ?? 0),
|
||||||
|
]));
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->execute() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an address.
|
||||||
|
*
|
||||||
|
* @param int $addressId Address ID.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function deleteAddress(int $addressId): bool
|
||||||
|
{
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
|
||||||
|
$query->delete($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $addressId)
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->execute() !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shopping cart model — session-based with DB persistence.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CartModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the current session identifier.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getSessionId(): string
|
||||||
|
{
|
||||||
|
return Factory::getApplication()->getSession()->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user ID (0 for guests).
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getUserId(): int
|
||||||
|
{
|
||||||
|
return (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all cart items for the current user/session.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getItems(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_cart'));
|
||||||
|
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
if ($userId > 0)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($this->getSessionId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->order($db->quoteName('created') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadAssocList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item to the cart.
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
* @param int $quantity Quantity to add.
|
||||||
|
*
|
||||||
|
* @return bool True on success.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function addItem(int $productId, int $quantity = 1): bool
|
||||||
|
{
|
||||||
|
// Fetch product info from Dolibarr
|
||||||
|
$client = new DolibarrClient();
|
||||||
|
$product = $client->get('/products/' . $productId);
|
||||||
|
|
||||||
|
if ($product === null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate stock
|
||||||
|
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||||
|
|
||||||
|
if ($stockReel <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
$sessionId = $this->getSessionId();
|
||||||
|
|
||||||
|
// Check if this product already exists in cart
|
||||||
|
$existing = $this->findCartItem($productId);
|
||||||
|
|
||||||
|
if ($existing)
|
||||||
|
{
|
||||||
|
$newQty = (int) $existing['quantity'] + $quantity;
|
||||||
|
|
||||||
|
if ($newQty > $stockReel)
|
||||||
|
{
|
||||||
|
$newQty = (int) $stockReel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->updateItemQuantity((int) $existing['id'], $newQty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp quantity to stock
|
||||||
|
if ($quantity > $stockReel)
|
||||||
|
{
|
||||||
|
$quantity = (int) $stockReel;
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = $this->getTable('Cart', 'Administrator');
|
||||||
|
$data = [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'dolibarr_product_id' => $productId,
|
||||||
|
'product_ref' => $product['ref'] ?? '',
|
||||||
|
'product_label' => $product['label'] ?? '',
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'unit_price' => (float) ($product['price_ttc'] ?? $product['price'] ?? 0),
|
||||||
|
'tax_rate' => (float) ($product['tva_tx'] ?? 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
$table->bind($data);
|
||||||
|
|
||||||
|
if (!$table->check())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $table->store();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update quantity of a cart item.
|
||||||
|
*
|
||||||
|
* @param int $cartItemId Cart row ID.
|
||||||
|
* @param int $quantity New quantity.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function updateItemQuantity(int $cartItemId, int $quantity): bool
|
||||||
|
{
|
||||||
|
if ($quantity < 1)
|
||||||
|
{
|
||||||
|
return $this->removeItem($cartItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->update($db->quoteName('#__mokodolijoomshop_cart'))
|
||||||
|
->set($db->quoteName('quantity') . ' = ' . $quantity)
|
||||||
|
->where($db->quoteName('id') . ' = ' . $cartItemId);
|
||||||
|
|
||||||
|
$this->addOwnerCondition($query);
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->execute() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an item from the cart.
|
||||||
|
*
|
||||||
|
* @param int $cartItemId Cart row ID.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function removeItem(int $cartItemId): bool
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->delete($db->quoteName('#__mokodolijoomshop_cart'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $cartItemId);
|
||||||
|
|
||||||
|
$this->addOwnerCondition($query);
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->execute() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all items from the current cart.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function clearCart(): bool
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->delete($db->quoteName('#__mokodolijoomshop_cart'));
|
||||||
|
$this->addOwnerCondition($query);
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->execute() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge guest session cart into the logged-in user's cart.
|
||||||
|
*
|
||||||
|
* @param string $sessionId Guest session ID.
|
||||||
|
* @param int $userId Logged-in user ID.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function mergeGuestCart(string $sessionId, int $userId): void
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
|
||||||
|
// Update guest cart items to belong to the user
|
||||||
|
$query->update($db->quoteName('#__mokodolijoomshop_cart'))
|
||||||
|
->set($db->quoteName('user_id') . ' = ' . $userId)
|
||||||
|
->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId))
|
||||||
|
->where($db->quoteName('user_id') . ' = 0');
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
$db->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cart items older than the specified number of hours.
|
||||||
|
*
|
||||||
|
* @param int $hours Age threshold in hours.
|
||||||
|
*
|
||||||
|
* @return int Number of rows deleted.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function cleanExpired(int $hours = 72): int
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$cutoff = Factory::getDate('-' . $hours . ' hours')->toSql();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->delete($db->quoteName('#__mokodolijoomshop_cart'))
|
||||||
|
->where($db->quoteName('modified') . ' < ' . $db->quote($cutoff))
|
||||||
|
->where($db->quoteName('user_id') . ' = 0');
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
$db->execute();
|
||||||
|
|
||||||
|
return $db->getAffectedRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cart totals.
|
||||||
|
*
|
||||||
|
* @return array{subtotal: float, tax: float, total: float, count: int}
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getTotals(): array
|
||||||
|
{
|
||||||
|
$items = $this->getItems();
|
||||||
|
$subtotal = 0.0;
|
||||||
|
$tax = 0.0;
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item)
|
||||||
|
{
|
||||||
|
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||||
|
$lineTax = $lineTotal * ((float) $item['tax_rate'] / 100);
|
||||||
|
$subtotal += $lineTotal;
|
||||||
|
$tax += $lineTax;
|
||||||
|
$count += (int) $item['quantity'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subtotal' => $subtotal,
|
||||||
|
'tax' => $tax,
|
||||||
|
'total' => $subtotal + $tax,
|
||||||
|
'count' => $count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate stock levels for all cart items against Dolibarr.
|
||||||
|
*
|
||||||
|
* @return array Array of items with insufficient stock: [product_id => available_qty].
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function validateStock(): array
|
||||||
|
{
|
||||||
|
$client = new DolibarrClient();
|
||||||
|
$items = $this->getItems();
|
||||||
|
$problems = [];
|
||||||
|
|
||||||
|
foreach ($items as $item)
|
||||||
|
{
|
||||||
|
$product = $client->get('/products/' . (int) $item['dolibarr_product_id']);
|
||||||
|
|
||||||
|
if ($product === null)
|
||||||
|
{
|
||||||
|
$problems[(int) $item['dolibarr_product_id']] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||||
|
|
||||||
|
if ((int) $item['quantity'] > $stockReel)
|
||||||
|
{
|
||||||
|
$problems[(int) $item['dolibarr_product_id']] = $stockReel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing cart item by product ID for the current user/session.
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
*
|
||||||
|
* @return array|null
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function findCartItem(int $productId): ?array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_cart'))
|
||||||
|
->where($db->quoteName('dolibarr_product_id') . ' = ' . $productId);
|
||||||
|
|
||||||
|
$this->addOwnerCondition($query);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$result = $db->loadAssoc();
|
||||||
|
|
||||||
|
return $result ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add user/session ownership condition to a query.
|
||||||
|
*
|
||||||
|
* @param \Joomla\Database\DatabaseQuery $query Query to modify.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private function addOwnerCondition($query): void
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$userId = $this->getUserId();
|
||||||
|
|
||||||
|
if ($userId > 0)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($this->getSessionId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category model — fetches product categories and their products from Dolibarr.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CategoryModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param array $config Configuration array.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct($config = [])
|
||||||
|
{
|
||||||
|
parent::__construct($config);
|
||||||
|
|
||||||
|
$this->client = new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single category by ID.
|
||||||
|
*
|
||||||
|
* @param int|null $id Category ID, or null to read from input.
|
||||||
|
*
|
||||||
|
* @return array|null
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getCategory(?int $id = null): ?array
|
||||||
|
{
|
||||||
|
if ($id === null)
|
||||||
|
{
|
||||||
|
$id = Factory::getApplication()->input->getInt('id', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->get('/categories/' . $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all product categories as a flat list.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getAllCategories(): array
|
||||||
|
{
|
||||||
|
$categories = $this->client->get('/categories', [
|
||||||
|
'sortfield' => 't.label',
|
||||||
|
'sortorder' => 'ASC',
|
||||||
|
'type' => 'product',
|
||||||
|
'limit' => 200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $categories ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a hierarchical category tree.
|
||||||
|
*
|
||||||
|
* @return array Nested array with 'children' key.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getCategoryTree(): array
|
||||||
|
{
|
||||||
|
$flat = $this->getAllCategories();
|
||||||
|
$tree = [];
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
// Index by ID
|
||||||
|
foreach ($flat as $cat)
|
||||||
|
{
|
||||||
|
$cat['children'] = [];
|
||||||
|
$map[(int) $cat['id']] = $cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
foreach ($map as $id => &$cat)
|
||||||
|
{
|
||||||
|
$parentId = (int) ($cat['fk_parent'] ?? 0);
|
||||||
|
|
||||||
|
if ($parentId > 0 && isset($map[$parentId]))
|
||||||
|
{
|
||||||
|
$map[$parentId]['children'][] = &$cat;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$tree[] = &$cat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($cat);
|
||||||
|
|
||||||
|
return $tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get products belonging to a category.
|
||||||
|
*
|
||||||
|
* @param int $categoryId Category ID.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getCategoryProducts(int $categoryId): array
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$perPage = (int) $params->get('products_per_page', 12);
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$page = $app->input->getInt('page', 0);
|
||||||
|
|
||||||
|
$products = $this->client->get('/products', [
|
||||||
|
'sortfield' => 't.ref',
|
||||||
|
'sortorder' => 'ASC',
|
||||||
|
'limit' => $perPage,
|
||||||
|
'page' => $page,
|
||||||
|
'category' => $categoryId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $products ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build breadcrumb trail for a category.
|
||||||
|
*
|
||||||
|
* @param int $categoryId Category ID.
|
||||||
|
*
|
||||||
|
* @return array Array of [id, label] from root to current.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getBreadcrumbs(int $categoryId): array
|
||||||
|
{
|
||||||
|
$crumbs = [];
|
||||||
|
$visited = [];
|
||||||
|
$current = $categoryId;
|
||||||
|
|
||||||
|
while ($current > 0 && !isset($visited[$current]))
|
||||||
|
{
|
||||||
|
$visited[$current] = true;
|
||||||
|
$cat = $this->client->get('/categories/' . $current);
|
||||||
|
|
||||||
|
if ($cat === null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
array_unshift($crumbs, [
|
||||||
|
'id' => (int) $cat['id'],
|
||||||
|
'label' => $cat['label'] ?? '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = (int) ($cat['fk_parent'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $crumbs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Service\CustomerSyncService;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Service\OrderService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout model — handles the full checkout process.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class CheckoutModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the current user can checkout (based on checkout_mode config).
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function canCheckout(): bool
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$mode = $params->get('checkout_mode', 'both');
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
if ($mode === 'registered' && $userId === 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the configured checkout mode.
|
||||||
|
*
|
||||||
|
* @return string 'guest', 'registered', or 'both'.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getCheckoutMode(): string
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return $params->get('checkout_mode', 'both');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the checkout.
|
||||||
|
*
|
||||||
|
* @param array $billingData Billing form data.
|
||||||
|
* @param array $cartItems Cart items from CartModel.
|
||||||
|
* @param array $totals Cart totals.
|
||||||
|
*
|
||||||
|
* @return array|null Order result with refs, or null on failure.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function processCheckout(array $billingData, array $cartItems, array $totals): ?array
|
||||||
|
{
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
$customerService = new CustomerSyncService();
|
||||||
|
$orderService = new OrderService();
|
||||||
|
|
||||||
|
// Resolve or create the Dolibarr thirdparty
|
||||||
|
if ($userId > 0)
|
||||||
|
{
|
||||||
|
$thirdpartyId = $customerService->getOrCreateThirdparty($userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$thirdpartyId = $customerService->createGuestCustomer(
|
||||||
|
$billingData['name'] ?? 'Guest Customer',
|
||||||
|
$billingData['email'] ?? '',
|
||||||
|
$billingData['address'] ?? '',
|
||||||
|
$billingData['town'] ?? '',
|
||||||
|
$billingData['zip'] ?? '',
|
||||||
|
$billingData['phone'] ?? ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($thirdpartyId === null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order in Dolibarr
|
||||||
|
$order = $orderService->createOrder($thirdpartyId, $cartItems, [
|
||||||
|
'note_public' => $billingData['notes'] ?? '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($order === null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderId = (int) ($order['id'] ?? 0);
|
||||||
|
$orderRef = $order['ref'] ?? '';
|
||||||
|
|
||||||
|
// Create invoice from order
|
||||||
|
$invoice = $orderService->createInvoiceFromOrder($orderId);
|
||||||
|
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||||
|
$invoiceRef = $invoice['ref'] ?? '';
|
||||||
|
|
||||||
|
// Save local mapping
|
||||||
|
$orderService->saveOrderMapping(
|
||||||
|
$userId,
|
||||||
|
$orderId,
|
||||||
|
$invoiceId,
|
||||||
|
$thirdpartyId,
|
||||||
|
$orderRef,
|
||||||
|
$invoiceRef,
|
||||||
|
$totals['subtotal'],
|
||||||
|
$totals['total']
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'order_ref' => $orderRef,
|
||||||
|
'invoice_id' => $invoiceId,
|
||||||
|
'invoice_ref' => $invoiceRef,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order history model — retrieves user's orders from local mapping and Dolibarr.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class OrdersModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param array $config Configuration array.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct($config = [])
|
||||||
|
{
|
||||||
|
parent::__construct($config);
|
||||||
|
|
||||||
|
$this->client = new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get orders for the currently logged-in user.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getUserOrders(): array
|
||||||
|
{
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
if ($userId === 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||||
|
->order($db->quoteName('created') . ' DESC');
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadAssocList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single order detail from Dolibarr.
|
||||||
|
*
|
||||||
|
* @param int $orderId Dolibarr order ID.
|
||||||
|
*
|
||||||
|
* @return array|null
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getOrderDetail(int $orderId): ?array
|
||||||
|
{
|
||||||
|
// Verify the order belongs to the current user
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
if ($userId === 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select($db->quoteName('dolibarr_order_id'))
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||||
|
->where($db->quoteName('dolibarr_order_id') . ' = ' . $orderId);
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
if ($db->loadResult() === null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->get('/orders/' . $orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invoice PDF download URL from Dolibarr.
|
||||||
|
*
|
||||||
|
* @param int $invoiceId Dolibarr invoice ID.
|
||||||
|
*
|
||||||
|
* @return array|null Document info with download data.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getInvoicePdf(int $invoiceId): ?array
|
||||||
|
{
|
||||||
|
// Verify user has access to this invoice
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
if ($userId === 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select($db->quoteName('dolibarr_invoice_id'))
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||||
|
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||||
|
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
if ($db->loadResult() === null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invoice documents
|
||||||
|
$docs = $this->client->get('/documents', [
|
||||||
|
'modulepart' => 'invoice',
|
||||||
|
'id' => $invoiceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (empty($docs))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find PDF
|
||||||
|
foreach ($docs as $doc)
|
||||||
|
{
|
||||||
|
if (isset($doc['relativename']) && str_ends_with($doc['relativename'], '.pdf'))
|
||||||
|
{
|
||||||
|
return $doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get real-time order status from Dolibarr.
|
||||||
|
*
|
||||||
|
* @param int $orderId Dolibarr order ID.
|
||||||
|
*
|
||||||
|
* @return string Status label.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getOrderStatus(int $orderId): string
|
||||||
|
{
|
||||||
|
$order = $this->client->get('/orders/' . $orderId);
|
||||||
|
|
||||||
|
if ($order === null)
|
||||||
|
{
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusMap = [
|
||||||
|
-1 => 'cancelled',
|
||||||
|
0 => 'draft',
|
||||||
|
1 => 'validated',
|
||||||
|
2 => 'shipped',
|
||||||
|
3 => 'delivered',
|
||||||
|
];
|
||||||
|
|
||||||
|
$statusCode = (int) ($order['statut'] ?? $order['status'] ?? 0);
|
||||||
|
|
||||||
|
return $statusMap[$statusCode] ?? 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single product model — fetches product detail from Dolibarr API.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class ProductModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param array $config Configuration array.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct($config = [])
|
||||||
|
{
|
||||||
|
parent::__construct($config);
|
||||||
|
|
||||||
|
$this->client = new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single product by ID.
|
||||||
|
*
|
||||||
|
* @param int|null $id Product ID, or null to read from input.
|
||||||
|
*
|
||||||
|
* @return array|null Product data or null if not found.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getItem(?int $id = null): ?array
|
||||||
|
{
|
||||||
|
if ($id === null)
|
||||||
|
{
|
||||||
|
$id = Factory::getApplication()->input->getInt('id', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->get('/products/' . $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stock level for a product.
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
*
|
||||||
|
* @return float Total stock across all warehouses.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getStock(int $productId): float
|
||||||
|
{
|
||||||
|
$stockData = $this->client->get('/products/' . $productId . '/stock');
|
||||||
|
|
||||||
|
if ($stockData === null)
|
||||||
|
{
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum stock across warehouses
|
||||||
|
$total = 0.0;
|
||||||
|
|
||||||
|
if (isset($stockData['stock_warehouses']) && \is_array($stockData['stock_warehouses']))
|
||||||
|
{
|
||||||
|
foreach ($stockData['stock_warehouses'] as $warehouse)
|
||||||
|
{
|
||||||
|
$total += (float) ($warehouse['real'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$total = (float) ($stockData['stock_reel'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product images from Dolibarr documents API.
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
* @param string $ref Product reference for path building.
|
||||||
|
*
|
||||||
|
* @return array Array of image URLs.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getImages(int $productId, string $ref = ''): array
|
||||||
|
{
|
||||||
|
$docs = $this->client->get('/documents', [
|
||||||
|
'modulepart' => 'product',
|
||||||
|
'id' => $productId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($docs === null || !\is_array($docs))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = [];
|
||||||
|
|
||||||
|
foreach ($docs as $doc)
|
||||||
|
{
|
||||||
|
if (isset($doc['relativename']) && preg_match('/\.(jpe?g|png|gif|webp)$/i', $doc['relativename']))
|
||||||
|
{
|
||||||
|
$images[] = [
|
||||||
|
'name' => $doc['name'] ?? basename($doc['relativename']),
|
||||||
|
'url' => $doc['fullname'] ?? $doc['relativename'],
|
||||||
|
'encoded' => $doc['content'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $images;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get related products from the same category.
|
||||||
|
*
|
||||||
|
* @param int $productId Current product ID.
|
||||||
|
* @param int $limit Number of related products to return.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getRelated(int $productId, int $limit = 4): array
|
||||||
|
{
|
||||||
|
// Get categories for this product
|
||||||
|
$categories = $this->client->get('/products/' . $productId . '/categories');
|
||||||
|
|
||||||
|
if (empty($categories))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$catId = (int) $categories[0]['id'];
|
||||||
|
$products = $this->client->get('/categories/' . $catId . '/objects', [
|
||||||
|
'type' => 'product',
|
||||||
|
'limit' => $limit + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (empty($products))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the current product from related
|
||||||
|
return array_values(array_filter($products, function ($p) use ($productId) {
|
||||||
|
return (int) $p['id'] !== $productId;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Products list model — fetches products from Dolibarr API.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class ProductsModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DolibarrClient
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
private DolibarrClient $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param array $config Configuration array.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function __construct($config = [])
|
||||||
|
{
|
||||||
|
parent::__construct($config);
|
||||||
|
|
||||||
|
$this->client = new DolibarrClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of products from Dolibarr.
|
||||||
|
*
|
||||||
|
* @return array Array of product objects.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getItems(): array
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$perPage = (int) $params->get('products_per_page', 12);
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$page = $app->input->getInt('page', 0);
|
||||||
|
$categoryId = $app->input->getInt('category_id', 0);
|
||||||
|
|
||||||
|
$query = [
|
||||||
|
'sortfield' => 't.ref',
|
||||||
|
'sortorder' => 'ASC',
|
||||||
|
'limit' => $perPage,
|
||||||
|
'page' => $page,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($categoryId > 0)
|
||||||
|
{
|
||||||
|
$query['category'] = $categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = $this->client->get('/products', $query);
|
||||||
|
|
||||||
|
if ($products === null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only saleable products
|
||||||
|
return array_values(array_filter($products, function ($product) {
|
||||||
|
return !empty($product['status_buy']) || !empty($product['tosell']) || ((int) ($product['status'] ?? 0)) === 1;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product categories from Dolibarr.
|
||||||
|
*
|
||||||
|
* @return array Array of category objects.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getCategories(): array
|
||||||
|
{
|
||||||
|
$categories = $this->client->get('/categories', [
|
||||||
|
'sortfield' => 't.label',
|
||||||
|
'sortorder' => 'ASC',
|
||||||
|
'type' => 'product',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $categories ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total product count for pagination.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getTotal(): int
|
||||||
|
{
|
||||||
|
$products = $this->client->get('/products', [
|
||||||
|
'limit' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($products === null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \count($products);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of products per page.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getPerPage(): int
|
||||||
|
{
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
return (int) $params->get('products_per_page', 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||||
|
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wishlist model — save for later functionality.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class WishlistModel extends BaseDatabaseModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get wishlist items for the current user.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function getItems(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
$query->select('*')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||||
|
->order($db->quoteName('created') . ' DESC');
|
||||||
|
|
||||||
|
if ($userId > 0)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||||
|
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadAssocList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a product to the wishlist.
|
||||||
|
*
|
||||||
|
* @param int $productId Dolibarr product ID.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function addItem(int $productId): bool
|
||||||
|
{
|
||||||
|
$client = new DolibarrClient();
|
||||||
|
$product = $client->get('/products/' . $productId);
|
||||||
|
|
||||||
|
if ($product === null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||||
|
|
||||||
|
// Check if already in wishlist
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||||
|
->where($db->quoteName('dolibarr_product_id') . ' = ' . $productId);
|
||||||
|
|
||||||
|
if ($userId > 0)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
if ((int) $db->loadResult() > 0)
|
||||||
|
{
|
||||||
|
return true; // Already in wishlist
|
||||||
|
}
|
||||||
|
|
||||||
|
$insert = $db->getQuery(true);
|
||||||
|
$insert->insert($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||||
|
->columns(['user_id', 'session_id', 'dolibarr_product_id', 'product_ref', 'product_label'])
|
||||||
|
->values(implode(',', [
|
||||||
|
$userId,
|
||||||
|
$db->quote($sessionId),
|
||||||
|
$productId,
|
||||||
|
$db->quote($product['ref'] ?? ''),
|
||||||
|
$db->quote($product['label'] ?? ''),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$db->setQuery($insert);
|
||||||
|
|
||||||
|
return $db->execute() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a product from the wishlist.
|
||||||
|
*
|
||||||
|
* @param int $wishlistItemId Wishlist row ID.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function removeItem(int $wishlistItemId): bool
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
|
||||||
|
$query->delete($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $wishlistItemId);
|
||||||
|
|
||||||
|
if ($userId > 0)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||||
|
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->execute() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge guest wishlist into user account on login.
|
||||||
|
*
|
||||||
|
* @param string $sessionId Guest session ID.
|
||||||
|
* @param int $userId User ID.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function mergeGuestWishlist(string $sessionId, int $userId): void
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
$query->update($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||||
|
->set($db->quoteName('user_id') . ' = ' . $userId)
|
||||||
|
->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId))
|
||||||
|
->where($db->quoteName('user_id') . ' = 0');
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
$db->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Application\SiteApplication;
|
||||||
|
use Joomla\CMS\Component\Router\RouterBase;
|
||||||
|
use Joomla\CMS\Menu\AbstractMenu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEF URL router for com_mokodolijoomshop.
|
||||||
|
*
|
||||||
|
* URL patterns:
|
||||||
|
* /shop → products view
|
||||||
|
* /shop/cart → cart view
|
||||||
|
* /shop/checkout → checkout view
|
||||||
|
* /shop/my-orders → orders view
|
||||||
|
* /shop/category/{id} → category view
|
||||||
|
* /shop/product/{id} → product view
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class Router extends RouterBase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Build SEF URL segments from query parameters.
|
||||||
|
*
|
||||||
|
* @param array &$query Query parameters.
|
||||||
|
*
|
||||||
|
* @return array URL segments.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function build(&$query): array
|
||||||
|
{
|
||||||
|
$segments = [];
|
||||||
|
$view = $query['view'] ?? 'products';
|
||||||
|
|
||||||
|
unset($query['view']);
|
||||||
|
|
||||||
|
switch ($view)
|
||||||
|
{
|
||||||
|
case 'products':
|
||||||
|
// No extra segment — the menu item handles it
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'product':
|
||||||
|
$segments[] = 'product';
|
||||||
|
|
||||||
|
if (isset($query['id']))
|
||||||
|
{
|
||||||
|
$segments[] = $query['id'];
|
||||||
|
unset($query['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'category':
|
||||||
|
$segments[] = 'category';
|
||||||
|
|
||||||
|
if (isset($query['id']))
|
||||||
|
{
|
||||||
|
$segments[] = $query['id'];
|
||||||
|
unset($query['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cart':
|
||||||
|
$segments[] = 'cart';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'checkout':
|
||||||
|
$segments[] = 'checkout';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'orders':
|
||||||
|
$segments[] = 'my-orders';
|
||||||
|
|
||||||
|
if (isset($query['order_id']))
|
||||||
|
{
|
||||||
|
$segments[] = $query['order_id'];
|
||||||
|
unset($query['order_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle task-based URLs (cart.add, cart.remove, etc.)
|
||||||
|
if (isset($query['task']))
|
||||||
|
{
|
||||||
|
// Keep task in query for controller routing
|
||||||
|
}
|
||||||
|
|
||||||
|
return $segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SEF URL segments into query parameters.
|
||||||
|
*
|
||||||
|
* @param array &$segments URL segments.
|
||||||
|
*
|
||||||
|
* @return array Query parameters.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function parse(&$segments): array
|
||||||
|
{
|
||||||
|
$vars = [];
|
||||||
|
$count = \count($segments);
|
||||||
|
|
||||||
|
if ($count === 0)
|
||||||
|
{
|
||||||
|
$vars['view'] = 'products';
|
||||||
|
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $segments[0];
|
||||||
|
|
||||||
|
switch ($first)
|
||||||
|
{
|
||||||
|
case 'product':
|
||||||
|
$vars['view'] = 'product';
|
||||||
|
|
||||||
|
if ($count > 1)
|
||||||
|
{
|
||||||
|
$vars['id'] = (int) $segments[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'category':
|
||||||
|
$vars['view'] = 'category';
|
||||||
|
|
||||||
|
if ($count > 1)
|
||||||
|
{
|
||||||
|
$vars['id'] = (int) $segments[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cart':
|
||||||
|
$vars['view'] = 'cart';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'checkout':
|
||||||
|
$vars['view'] = 'checkout';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'my-orders':
|
||||||
|
$vars['view'] = 'orders';
|
||||||
|
|
||||||
|
if ($count > 1)
|
||||||
|
{
|
||||||
|
$vars['order_id'] = (int) $segments[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Try to resolve as product ID or fall back
|
||||||
|
if (is_numeric($first))
|
||||||
|
{
|
||||||
|
$vars['view'] = 'product';
|
||||||
|
$vars['id'] = (int) $first;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$vars['view'] = 'products';
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = [];
|
||||||
|
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\View\Cart;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shopping cart view.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array Cart items.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $items = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Cart totals (subtotal, tax, total, count).
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $totals = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Currency code.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected string $currency = 'USD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the cart.
|
||||||
|
*
|
||||||
|
* @param string $tpl Template name.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function display($tpl = null): void
|
||||||
|
{
|
||||||
|
$model = $this->getModel();
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
$this->items = $model->getItems();
|
||||||
|
$this->totals = $model->getTotals();
|
||||||
|
$this->currency = $params->get('currency', 'USD');
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\View\Category;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category landing page view.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array|null Current category data.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected ?array $category = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Products in this category.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $items = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Category tree for sidebar.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $categoryTree = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Breadcrumbs path.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $breadcrumbs = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Currency code.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected string $currency = 'USD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int Current page.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected int $page = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int Per page count.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected int $perPage = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the category page.
|
||||||
|
*
|
||||||
|
* @param string $tpl Template name.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function display($tpl = null): void
|
||||||
|
{
|
||||||
|
$model = $this->getModel();
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
$categoryId = $app->input->getInt('id', 0);
|
||||||
|
|
||||||
|
$this->category = $model->getCategory($categoryId);
|
||||||
|
$this->categoryTree = $model->getCategoryTree();
|
||||||
|
$this->currency = $params->get('currency', 'USD');
|
||||||
|
$this->page = $app->input->getInt('page', 0);
|
||||||
|
$this->perPage = (int) $params->get('products_per_page', 12);
|
||||||
|
|
||||||
|
if ($this->category !== null)
|
||||||
|
{
|
||||||
|
$this->items = $model->getCategoryProducts($categoryId);
|
||||||
|
$this->breadcrumbs = $model->getBreadcrumbs($categoryId);
|
||||||
|
|
||||||
|
$app->getDocument()->setTitle(htmlspecialchars($this->category['label'] ?? 'Category'));
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\View\Checkout;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout view.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array Cart items.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $cartItems = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Cart totals.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $totals = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Currency code.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected string $currency = 'USD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Checkout mode.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected string $checkoutMode = 'both';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \Joomla\CMS\User\User|null Current user.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected $user = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array|null Order result for confirmation page.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected ?array $orderResult = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the checkout view.
|
||||||
|
*
|
||||||
|
* @param string $tpl Template name.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function display($tpl = null): void
|
||||||
|
{
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$layout = $app->input->getString('layout', 'default');
|
||||||
|
|
||||||
|
$this->currency = $params->get('currency', 'USD');
|
||||||
|
$this->checkoutMode = $params->get('checkout_mode', 'both');
|
||||||
|
$this->user = $app->getIdentity();
|
||||||
|
|
||||||
|
if ($layout === 'confirmation')
|
||||||
|
{
|
||||||
|
$this->orderResult = $app->getSession()->get('mokodolijoomshop.order_result');
|
||||||
|
$app->getSession()->clear('mokodolijoomshop.order_result');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $cartModel */
|
||||||
|
$cartModel = $this->getModel('Cart');
|
||||||
|
$this->cartItems = $cartModel->getItems();
|
||||||
|
$this->totals = $cartModel->getTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\View\Orders;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order history view (My Orders).
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array User's orders.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $orders = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array|null Single order detail.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected ?array $orderDetail = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Currency.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected string $currency = 'USD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Whether user is logged in.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected bool $isGuest = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the orders view.
|
||||||
|
*
|
||||||
|
* @param string $tpl Template name.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function display($tpl = null): void
|
||||||
|
{
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
$model = $this->getModel();
|
||||||
|
|
||||||
|
$this->currency = $params->get('currency', 'USD');
|
||||||
|
$this->isGuest = empty($app->getIdentity()->id);
|
||||||
|
|
||||||
|
if (!$this->isGuest)
|
||||||
|
{
|
||||||
|
$orderId = $app->input->getInt('order_id', 0);
|
||||||
|
|
||||||
|
if ($orderId > 0)
|
||||||
|
{
|
||||||
|
$this->orderDetail = $model->getOrderDetail($orderId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$this->orders = $model->getUserOrders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\View\Product;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single product detail view.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array|null Product data.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected ?array $item = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var float Stock quantity.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected float $stock = 0.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Product images.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $images = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Related products.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $related = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Currency code.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected string $currency = 'USD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the product detail page.
|
||||||
|
*
|
||||||
|
* @param string $tpl Template name.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function display($tpl = null): void
|
||||||
|
{
|
||||||
|
$model = $this->getModel();
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
$this->item = $model->getItem();
|
||||||
|
$this->currency = $params->get('currency', 'USD');
|
||||||
|
|
||||||
|
if ($this->item !== null)
|
||||||
|
{
|
||||||
|
$productId = (int) $this->item['id'];
|
||||||
|
$this->stock = $model->getStock($productId);
|
||||||
|
$this->images = $model->getImages($productId, $this->item['ref'] ?? '');
|
||||||
|
$this->related = $model->getRelated($productId);
|
||||||
|
|
||||||
|
// Set page title
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$app->getDocument()->setTitle(htmlspecialchars($this->item['label'] ?? 'Product'));
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoDoliJoomShop\Site\View\Products;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
use Joomla\CMS\Component\ComponentHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product catalog listing view.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array Product items from Dolibarr.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $items = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Product categories.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected array $categories = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int Current page number.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected int $page = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int Active category filter.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected int $categoryId = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Currency code.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected string $currency = 'USD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int Products per page.
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
protected int $perPage = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the products catalog.
|
||||||
|
*
|
||||||
|
* @param string $tpl Template name.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public function display($tpl = null): void
|
||||||
|
{
|
||||||
|
$model = $this->getModel();
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||||
|
|
||||||
|
$this->items = $model->getItems();
|
||||||
|
$this->categories = $model->getCategories();
|
||||||
|
$this->page = $app->input->getInt('page', 0);
|
||||||
|
$this->categoryId = $app->input->getInt('category_id', 0);
|
||||||
|
$this->currency = $params->get('currency', 'USD');
|
||||||
|
$this->perPage = $model->getPerPage();
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\HTML\HTMLHelper;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Cart\HtmlView $this */
|
||||||
|
|
||||||
|
$currency = htmlspecialchars($this->currency);
|
||||||
|
?>
|
||||||
|
<div class="com-mokodolijoomshop-cart">
|
||||||
|
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART'); ?></h2>
|
||||||
|
|
||||||
|
<?php if (empty($this->items)) : ?>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'); ?>
|
||||||
|
</div>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||||
|
</a>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_REF'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE'); ?></th>
|
||||||
|
<th class="text-center"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($this->items as $item) : ?>
|
||||||
|
<?php $lineTotal = (float) $item['unit_price'] * (int) $item['quantity']; ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . (int) $item['dolibarr_product_id']); ?>">
|
||||||
|
<?php echo htmlspecialchars($item['product_label']); ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted"><?php echo htmlspecialchars($item['product_ref']); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format((float) $item['unit_price'], 2); ?> <?php echo $currency; ?></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.update'); ?>" method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="cart_item_id" value="<?php echo (int) $item['id']; ?>" />
|
||||||
|
<input type="number" name="quantity" value="<?php echo (int) $item['quantity']; ?>" min="1" max="999" class="form-control form-control-sm d-inline-block" style="width: 70px;" onchange="this.form.submit();" />
|
||||||
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="text-end fw-bold"><?php echo number_format($lineTotal, 2); ?> <?php echo $currency; ?></td>
|
||||||
|
<td>
|
||||||
|
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.remove'); ?>" method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="cart_item_id" value="<?php echo (int) $item['id']; ?>" />
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="<?php echo Text::_('JACTION_DELETE'); ?>">
|
||||||
|
<span class="icon-trash" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-content-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format($this->totals['subtotal'], 2); ?> <?php echo $currency; ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php if ($this->totals['tax'] > 0) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TAX'); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format($this->totals['tax'], 2); ?> <?php echo $currency; ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<tr class="fw-bold fs-5">
|
||||||
|
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format($this->totals['total'], 2); ?> <?php echo $currency; ?></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=checkout'); ?>" class="btn btn-primary btn-lg">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT'); ?>
|
||||||
|
</a>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-outline-secondary">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metadata>
|
||||||
|
<layout title="COM_MOKODOLIJOOMSHOP_CART">
|
||||||
|
<message>COM_MOKODOLIJOOMSHOP_CART_DESC</message>
|
||||||
|
</layout>
|
||||||
|
</metadata>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Category\HtmlView $this */
|
||||||
|
|
||||||
|
if ($this->category === null) :
|
||||||
|
?>
|
||||||
|
<div class="alert alert-warning"><?php echo Text::_('JGLOBAL_RESOURCE_NOT_FOUND'); ?></div>
|
||||||
|
<?php return;
|
||||||
|
endif;
|
||||||
|
|
||||||
|
$currency = htmlspecialchars($this->currency);
|
||||||
|
$catLabel = htmlspecialchars($this->category['label'] ?? '');
|
||||||
|
$catDesc = $this->category['description'] ?? '';
|
||||||
|
$categoryId = (int) $this->category['id'];
|
||||||
|
?>
|
||||||
|
<div class="com-mokodolijoomshop-category">
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php foreach ($this->breadcrumbs as $i => $crumb) : ?>
|
||||||
|
<?php if ($i === \count($this->breadcrumbs) - 1) : ?>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page"><?php echo htmlspecialchars($crumb['label']); ?></li>
|
||||||
|
<?php else : ?>
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=category&id=' . (int) $crumb['id']); ?>">
|
||||||
|
<?php echo htmlspecialchars($crumb['label']); ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Category sidebar -->
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CATEGORIES'); ?></h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<?php echo mokoshop_render_category_tree($this->categoryTree, $categoryId); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products grid -->
|
||||||
|
<div class="col-md-9">
|
||||||
|
<h2><?php echo $catLabel; ?></h2>
|
||||||
|
<?php if ($catDesc) : ?>
|
||||||
|
<p class="text-muted"><?php echo $catDesc; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($this->items)) : ?>
|
||||||
|
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_PRODUCTS'); ?></div>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||||
|
<?php foreach ($this->items as $product) : ?>
|
||||||
|
<?php
|
||||||
|
$productId = (int) $product['id'];
|
||||||
|
$label = htmlspecialchars($product['label'] ?? $product['ref'] ?? '');
|
||||||
|
$price = (float) ($product['price_ttc'] ?? $product['price'] ?? 0);
|
||||||
|
$detailLink = Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . $productId);
|
||||||
|
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||||
|
$inStock = $stockReel > 0;
|
||||||
|
?>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 product-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">
|
||||||
|
<a href="<?php echo $detailLink; ?>"><?php echo $label; ?></a>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-bold"><?php echo number_format($price, 2); ?> <?php echo $currency; ?></span>
|
||||||
|
<?php if ($inStock) : ?>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId); ?>" class="btn btn-sm btn-primary">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||||
|
</a>
|
||||||
|
<?php else : ?>
|
||||||
|
<span class="badge bg-secondary"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php $baseUrl = 'index.php?option=com_mokodolijoomshop&view=category&id=' . $categoryId; ?>
|
||||||
|
<nav class="mt-4" aria-label="<?php echo Text::_('JLIB_HTML_PAGINATION'); ?>">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<?php if ($this->page > 0) : ?>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page - 1)); ?>">« <?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'); ?> »</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metadata>
|
||||||
|
<layout title="COM_MOKODOLIJOOMSHOP_CATEGORY">
|
||||||
|
<message>COM_MOKODOLIJOOMSHOP_CATEGORY_DESC</message>
|
||||||
|
</layout>
|
||||||
|
<fields name="params">
|
||||||
|
<fieldset name="request" label="COM_MOKODOLIJOOMSHOP_CATEGORY_OPTIONS">
|
||||||
|
<field
|
||||||
|
name="id"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKODOLIJOOMSHOP_CATEGORY_ID"
|
||||||
|
description="COM_MOKODOLIJOOMSHOP_CATEGORY_ID_DESC"
|
||||||
|
required="true"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</fields>
|
||||||
|
</metadata>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Checkout\HtmlView $this */
|
||||||
|
|
||||||
|
$order = $this->orderResult;
|
||||||
|
?>
|
||||||
|
<div class="com-mokodolijoomshop-checkout-confirmation">
|
||||||
|
<?php if ($order === null) : ?>
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDER_DATA'); ?>
|
||||||
|
</div>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<span class="icon-check-circle text-success" style="font-size: 4rem;" aria-hidden="true"></span>
|
||||||
|
<h2 class="mt-3"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_PLACED'); ?></h2>
|
||||||
|
|
||||||
|
<div class="card mx-auto mt-4" style="max-width: 400px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-5"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></dt>
|
||||||
|
<dd class="col-sm-7 fw-bold"><?php echo htmlspecialchars($order['order_ref'] ?? ''); ?></dd>
|
||||||
|
|
||||||
|
<?php if (!empty($order['invoice_ref'])) : ?>
|
||||||
|
<dt class="col-sm-5"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></dt>
|
||||||
|
<dd class="col-sm-7"><?php echo htmlspecialchars($order['invoice_ref']); ?></dd>
|
||||||
|
<?php endif; ?>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary mt-4">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\HTML\HTMLHelper;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Checkout\HtmlView $this */
|
||||||
|
|
||||||
|
$currency = htmlspecialchars($this->currency);
|
||||||
|
$isGuest = empty($this->user->id);
|
||||||
|
$userName = $isGuest ? '' : htmlspecialchars($this->user->name);
|
||||||
|
$userEmail = $isGuest ? '' : htmlspecialchars($this->user->email);
|
||||||
|
?>
|
||||||
|
<div class="com-mokodolijoomshop-checkout">
|
||||||
|
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT'); ?></h2>
|
||||||
|
|
||||||
|
<?php if (empty($this->cartItems)) : ?>
|
||||||
|
<div class="alert alert-warning"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'); ?></div>
|
||||||
|
<?php return; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($this->checkoutMode === 'registered' && $isGuest) : ?>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED'); ?>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_users&view=login'); ?>" class="btn btn-primary ms-2">
|
||||||
|
<?php echo Text::_('JLOGIN'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php return; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=checkout.process'); ?>" method="post" id="checkoutForm">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_DETAILS'); ?></h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="billing_name" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_NAME'); ?> *</label>
|
||||||
|
<input type="text" id="billing_name" name="billing_name" class="form-control" required value="<?php echo $userName; ?>" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="billing_email" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_EMAIL'); ?> *</label>
|
||||||
|
<input type="email" id="billing_email" name="billing_email" class="form-control" required value="<?php echo $userEmail; ?>" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="billing_address" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_ADDRESS'); ?></label>
|
||||||
|
<textarea id="billing_address" name="billing_address" class="form-control" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="billing_town" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_TOWN'); ?></label>
|
||||||
|
<input type="text" id="billing_town" name="billing_town" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="billing_zip" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_ZIP'); ?></label>
|
||||||
|
<input type="text" id="billing_zip" name="billing_zip" class="form-control" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="billing_phone" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_PHONE'); ?></label>
|
||||||
|
<input type="tel" id="billing_phone" name="billing_phone" class="form-control" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_NOTES'); ?></h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<textarea name="order_notes" class="form-control" rows="3" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_NOTES_PLACEHOLDER'); ?>"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||||
|
<span class="icon-cart" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PLACE_ORDER'); ?>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_SUMMARY'); ?></h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php foreach ($this->cartItems as $item) : ?>
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span>
|
||||||
|
<?php echo htmlspecialchars($item['product_label']); ?>
|
||||||
|
<small class="text-muted">× <?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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metadata>
|
||||||
|
<layout title="COM_MOKODOLIJOOMSHOP_CHECKOUT">
|
||||||
|
<message>COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC</message>
|
||||||
|
</layout>
|
||||||
|
</metadata>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Orders\HtmlView $this */
|
||||||
|
|
||||||
|
$currency = htmlspecialchars($this->currency);
|
||||||
|
?>
|
||||||
|
<div class="com-mokodolijoomshop-orders">
|
||||||
|
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_MY_ORDERS'); ?></h2>
|
||||||
|
|
||||||
|
<?php if ($this->isGuest) : ?>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS_LOGIN_REQUIRED'); ?>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_users&view=login'); ?>" class="btn btn-primary ms-2">
|
||||||
|
<?php echo Text::_('JLOGIN'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php return; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($this->orderDetail !== null) : ?>
|
||||||
|
<?php // Order detail view ?>
|
||||||
|
<?php
|
||||||
|
$order = $this->orderDetail;
|
||||||
|
$ref = htmlspecialchars($order['ref'] ?? '');
|
||||||
|
?>
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_MY_ORDERS'); ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active"><?php echo $ref; ?></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between">
|
||||||
|
<h4 class="mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?>: <?php echo $ref; ?></h4>
|
||||||
|
<span class="badge bg-info"><?php echo htmlspecialchars($order['statut_label'] ?? $order['status'] ?? ''); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE'); ?></th>
|
||||||
|
<th class="text-center"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($order['lines'] ?? [] as $line) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo htmlspecialchars($line['desc'] ?? $line['product_label'] ?? ''); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format((float) ($line['subprice'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||||
|
<td class="text-center"><?php echo (int) ($line['qty'] ?? 0); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format((float) ($line['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="fw-bold">
|
||||||
|
<td colspan="3" class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format((float) ($order['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php elseif (empty($this->orders)) : ?>
|
||||||
|
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||||
|
</a>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
|
||||||
|
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($this->orders as $order) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo htmlspecialchars($order['created'] ?? ''); ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders&order_id=' . (int) $order['dolibarr_order_id']); ?>">
|
||||||
|
<?php echo htmlspecialchars($order['order_ref']); ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
|
||||||
|
<td class="text-end"><?php echo number_format((float) ($order['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary"><?php echo htmlspecialchars($order['status'] ?? ''); ?></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders&order_id=' . (int) $order['dolibarr_order_id']); ?>" class="btn btn-sm btn-outline-primary">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_VIEW_DETAIL'); ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metadata>
|
||||||
|
<layout title="COM_MOKODOLIJOOMSHOP_MY_ORDERS">
|
||||||
|
<message>COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC</message>
|
||||||
|
</layout>
|
||||||
|
</metadata>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Product\HtmlView $this */
|
||||||
|
|
||||||
|
if ($this->item === null) :
|
||||||
|
?>
|
||||||
|
<div class="alert alert-warning"><?php echo Text::_('JGLOBAL_RESOURCE_NOT_FOUND'); ?></div>
|
||||||
|
<?php return;
|
||||||
|
endif;
|
||||||
|
|
||||||
|
$product = $this->item;
|
||||||
|
$ref = htmlspecialchars($product['ref'] ?? '');
|
||||||
|
$label = htmlspecialchars($product['label'] ?? $ref);
|
||||||
|
$description = $product['description'] ?? '';
|
||||||
|
$priceHT = (float) ($product['price'] ?? 0);
|
||||||
|
$priceTTC = (float) ($product['price_ttc'] ?? $priceHT);
|
||||||
|
$barcode = htmlspecialchars($product['barcode'] ?? '');
|
||||||
|
$inStock = $this->stock > 0;
|
||||||
|
$productId = (int) $product['id'];
|
||||||
|
$addCartLink = Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php // Schema.org Product JSON-LD ?>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
<?php echo json_encode([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Product',
|
||||||
|
'name' => $label,
|
||||||
|
'description' => strip_tags($description),
|
||||||
|
'sku' => $ref,
|
||||||
|
'offers' => [
|
||||||
|
'@type' => 'Offer',
|
||||||
|
'price' => number_format($priceTTC, 2, '.', ''),
|
||||||
|
'priceCurrency' => $this->currency,
|
||||||
|
'availability' => $inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
|
||||||
|
],
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); ?>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="com-mokodolijoomshop-product">
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page"><?php echo $label; ?></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<?php if (!empty($this->images)) : ?>
|
||||||
|
<div class="product-gallery mb-3">
|
||||||
|
<?php foreach ($this->images as $i => $image) : ?>
|
||||||
|
<img
|
||||||
|
src="<?php echo htmlspecialchars($image['url']); ?>"
|
||||||
|
alt="<?php echo $label; ?>"
|
||||||
|
class="img-fluid rounded <?php echo $i > 0 ? 'mt-2' : ''; ?>"
|
||||||
|
loading="<?php echo $i === 0 ? 'eager' : 'lazy'; ?>"
|
||||||
|
/>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="height: 300px;">
|
||||||
|
<span class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_IMAGE'); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-7">
|
||||||
|
<h1><?php echo $label; ?></h1>
|
||||||
|
<p class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_REF'); ?>: <?php echo $ref; ?></p>
|
||||||
|
|
||||||
|
<?php if ($barcode) : ?>
|
||||||
|
<p class="small text-muted">Barcode: <?php echo $barcode; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="fs-3 fw-bold">
|
||||||
|
<?php echo number_format($priceTTC, 2); ?> <?php echo htmlspecialchars($this->currency); ?>
|
||||||
|
</span>
|
||||||
|
<?php if ($priceHT !== $priceTTC) : ?>
|
||||||
|
<br>
|
||||||
|
<span class="text-muted small">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE_HT'); ?>: <?php echo number_format($priceHT, 2); ?> <?php echo htmlspecialchars($this->currency); ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<?php if ($inStock) : ?>
|
||||||
|
<span class="badge bg-success"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_IN_STOCK'); ?></span>
|
||||||
|
<span class="text-muted small ms-2">(<?php echo (int) $this->stock; ?> <?php echo Text::_('COM_MOKODOLIJOOMSHOP_AVAILABLE'); ?>)</span>
|
||||||
|
<?php else : ?>
|
||||||
|
<span class="badge bg-danger"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($inStock) : ?>
|
||||||
|
<form action="<?php echo $addCartLink; ?>" method="post" class="mb-4">
|
||||||
|
<div class="input-group" style="max-width: 250px;">
|
||||||
|
<label for="quantity" class="visually-hidden"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></label>
|
||||||
|
<input type="number" id="quantity" name="quantity" value="1" min="1" max="<?php echo (int) $this->stock; ?>" class="form-control" />
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<span class="icon-cart" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($description) : ?>
|
||||||
|
<div class="product-description mt-4">
|
||||||
|
<h4><?php echo Text::_('COM_MOKODOLIJOOMSHOP_DESCRIPTION'); ?></h4>
|
||||||
|
<div><?php echo $description; ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($this->related)) : ?>
|
||||||
|
<div class="mt-5">
|
||||||
|
<h3><?php echo Text::_('COM_MOKODOLIJOOMSHOP_RELATED_PRODUCTS'); ?></h3>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-3">
|
||||||
|
<?php foreach (array_slice($this->related, 0, 4) as $rel) : ?>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . (int) $rel['id']); ?>">
|
||||||
|
<?php echo htmlspecialchars($rel['label'] ?? $rel['ref'] ?? ''); ?>
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
<span class="fw-bold">
|
||||||
|
<?php echo number_format((float) ($rel['price_ttc'] ?? $rel['price'] ?? 0), 2); ?>
|
||||||
|
<?php echo htmlspecialchars($this->currency); ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metadata>
|
||||||
|
<layout title="COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL">
|
||||||
|
<message>COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC</message>
|
||||||
|
</layout>
|
||||||
|
<fields name="params">
|
||||||
|
<fieldset name="request" label="COM_MOKODOLIJOOMSHOP_PRODUCT_OPTIONS">
|
||||||
|
<field
|
||||||
|
name="id"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKODOLIJOOMSHOP_PRODUCT_ID"
|
||||||
|
description="COM_MOKODOLIJOOMSHOP_PRODUCT_ID_DESC"
|
||||||
|
required="true"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</fields>
|
||||||
|
</metadata>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoDoliJoomShop
|
||||||
|
* @subpackage com_mokodolijoomshop
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Products\HtmlView $this */
|
||||||
|
?>
|
||||||
|
<div class="com-mokodolijoomshop-products">
|
||||||
|
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?></h2>
|
||||||
|
|
||||||
|
<?php if (!empty($this->categories)) : ?>
|
||||||
|
<nav class="shop-categories mb-4">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>"
|
||||||
|
class="btn btn-sm <?php echo $this->categoryId === 0 ? 'btn-primary' : 'btn-outline-secondary'; ?> me-1 mb-1">
|
||||||
|
<?php echo Text::_('JALL'); ?>
|
||||||
|
</a>
|
||||||
|
<?php foreach ($this->categories as $cat) : ?>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products&category_id=' . (int) $cat['id']); ?>"
|
||||||
|
class="btn btn-sm <?php echo $this->categoryId === (int) $cat['id'] ? 'btn-primary' : 'btn-outline-secondary'; ?> me-1 mb-1">
|
||||||
|
<?php echo htmlspecialchars($cat['label']); ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</nav>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($this->items)) : ?>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_PRODUCTS'); ?>
|
||||||
|
</div>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
|
||||||
|
<?php foreach ($this->items as $product) : ?>
|
||||||
|
<?php
|
||||||
|
$productId = (int) $product['id'];
|
||||||
|
$ref = htmlspecialchars($product['ref'] ?? '');
|
||||||
|
$label = htmlspecialchars($product['label'] ?? $ref);
|
||||||
|
$price = (float) ($product['price_ttc'] ?? $product['price'] ?? 0);
|
||||||
|
$description = htmlspecialchars(strip_tags($product['description'] ?? ''));
|
||||||
|
$detailLink = Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . $productId);
|
||||||
|
$addCartLink = Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId);
|
||||||
|
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||||
|
$inStock = $stockReel > 0;
|
||||||
|
?>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 product-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">
|
||||||
|
<a href="<?php echo $detailLink; ?>"><?php echo $label; ?></a>
|
||||||
|
</h5>
|
||||||
|
<p class="card-text text-muted small"><?php echo $ref; ?></p>
|
||||||
|
<?php if ($description) : ?>
|
||||||
|
<p class="card-text"><?php echo mb_strimwidth($description, 0, 120, '…'); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-bold fs-5">
|
||||||
|
<?php echo number_format($price, 2); ?>
|
||||||
|
<?php echo htmlspecialchars($this->currency); ?>
|
||||||
|
</span>
|
||||||
|
<?php if ($inStock) : ?>
|
||||||
|
<a href="<?php echo $addCartLink; ?>" class="btn btn-sm btn-primary">
|
||||||
|
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||||
|
</a>
|
||||||
|
<?php else : ?>
|
||||||
|
<span class="badge bg-secondary"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php // Pagination ?>
|
||||||
|
<?php $baseUrl = 'index.php?option=com_mokodolijoomshop&view=products'
|
||||||
|
. ($this->categoryId ? '&category_id=' . $this->categoryId : ''); ?>
|
||||||
|
<nav class="mt-4" aria-label="<?php echo Text::_('JLIB_HTML_PAGINATION'); ?>">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<?php if ($this->page > 0) : ?>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page - 1)); ?>">
|
||||||
|
« <?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'); ?> »
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metadata>
|
||||||
|
<layout title="COM_MOKODOLIJOOMSHOP_PRODUCTS">
|
||||||
|
<message>COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC</message>
|
||||||
|
</layout>
|
||||||
|
<fields name="params">
|
||||||
|
<fieldset name="basic" label="COM_MOKODOLIJOOMSHOP_FIELDSET_SHOP">
|
||||||
|
<field
|
||||||
|
name="products_per_page"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKODOLIJOOMSHOP_FIELD_PRODUCTS_PER_PAGE"
|
||||||
|
default=""
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
hint="COM_MOKODOLIJOOMSHOP_USE_GLOBAL"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sort_order"
|
||||||
|
type="list"
|
||||||
|
label="COM_MOKODOLIJOOMSHOP_SORT_BY"
|
||||||
|
default=""
|
||||||
|
>
|
||||||
|
<option value="">COM_MOKODOLIJOOMSHOP_USE_GLOBAL</option>
|
||||||
|
<option value="ref_asc">COM_MOKODOLIJOOMSHOP_SORT_REF_ASC</option>
|
||||||
|
<option value="price_asc">COM_MOKODOLIJOOMSHOP_SORT_PRICE_ASC</option>
|
||||||
|
<option value="price_desc">COM_MOKODOLIJOOMSHOP_SORT_PRICE_DESC</option>
|
||||||
|
<option value="newest">COM_MOKODOLIJOOMSHOP_SORT_NEWEST</option>
|
||||||
|
</field>
|
||||||
|
</fieldset>
|
||||||
|
</fields>
|
||||||
|
</metadata>
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
<?xml version='1.0' encoding='UTF-8'?>
|
|
||||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
VERSION: 01.00.00
|
|
||||||
-->
|
|
||||||
|
|
||||||
<updates>
|
|
||||||
|
|
||||||
<!-- 1. DEVELOPMENT -->
|
|
||||||
<update>
|
|
||||||
<name>MokoStandards-Template-Joomla-Plugin</name>
|
|
||||||
<description>MokoStandards-Template-Joomla-Plugin development build — unstable.</description>
|
|
||||||
<element>plg_system_yourplugin</element>
|
|
||||||
<type>plugin</type>
|
|
||||||
<folder>system</folder>
|
|
||||||
<client>site</client>
|
|
||||||
<version>01.00.00</version>
|
|
||||||
<creationDate>2026-04-26</creationDate>
|
|
||||||
<infourl title='MokoStandards-Template-Joomla-Plugin Dev'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/tag/development</infourl>
|
|
||||||
<downloads>
|
|
||||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/download/development/plg_system_yourplugin-01.00.00-dev.zip</downloadurl>
|
|
||||||
</downloads>
|
|
||||||
<tags><tag>development</tag></tags>
|
|
||||||
<maintainer>Moko Consulting</maintainer>
|
|
||||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
|
||||||
<targetplatform name='joomla' version='(5|6).*'/>
|
|
||||||
<php_minimum>8.1</php_minimum>
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<!-- 2. ALPHA -->
|
|
||||||
<update>
|
|
||||||
<name>MokoStandards-Template-Joomla-Plugin</name>
|
|
||||||
<description>MokoStandards-Template-Joomla-Plugin alpha build — early testing.</description>
|
|
||||||
<element>plg_system_yourplugin</element>
|
|
||||||
<type>plugin</type>
|
|
||||||
<folder>system</folder>
|
|
||||||
<client>site</client>
|
|
||||||
<version>01.00.00</version>
|
|
||||||
<creationDate>2026-04-26</creationDate>
|
|
||||||
<infourl title='MokoStandards-Template-Joomla-Plugin Alpha'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/tag/alpha</infourl>
|
|
||||||
<downloads>
|
|
||||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/download/alpha/plg_system_yourplugin-01.00.00-alpha.zip</downloadurl>
|
|
||||||
</downloads>
|
|
||||||
<tags><tag>alpha</tag></tags>
|
|
||||||
<maintainer>Moko Consulting</maintainer>
|
|
||||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
|
||||||
<targetplatform name='joomla' version='(5|6).*'/>
|
|
||||||
<php_minimum>8.1</php_minimum>
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<!-- 3. BETA -->
|
|
||||||
<update>
|
|
||||||
<name>MokoStandards-Template-Joomla-Plugin</name>
|
|
||||||
<description>MokoStandards-Template-Joomla-Plugin beta build — feature complete, stability testing.</description>
|
|
||||||
<element>plg_system_yourplugin</element>
|
|
||||||
<type>plugin</type>
|
|
||||||
<folder>system</folder>
|
|
||||||
<client>site</client>
|
|
||||||
<version>01.00.00</version>
|
|
||||||
<creationDate>2026-04-26</creationDate>
|
|
||||||
<infourl title='MokoStandards-Template-Joomla-Plugin Beta'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/tag/beta</infourl>
|
|
||||||
<downloads>
|
|
||||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/download/beta/plg_system_yourplugin-01.00.00-beta.zip</downloadurl>
|
|
||||||
</downloads>
|
|
||||||
<tags><tag>beta</tag></tags>
|
|
||||||
<maintainer>Moko Consulting</maintainer>
|
|
||||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
|
||||||
<targetplatform name='joomla' version='(5|6).*'/>
|
|
||||||
<php_minimum>8.1</php_minimum>
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<!-- 4. RC -->
|
|
||||||
<update>
|
|
||||||
<name>MokoStandards-Template-Joomla-Plugin</name>
|
|
||||||
<description>MokoStandards-Template-Joomla-Plugin release candidate — testing only.</description>
|
|
||||||
<element>plg_system_yourplugin</element>
|
|
||||||
<type>plugin</type>
|
|
||||||
<folder>system</folder>
|
|
||||||
<client>site</client>
|
|
||||||
<version>01.00.00</version>
|
|
||||||
<creationDate>2026-04-26</creationDate>
|
|
||||||
<infourl title='MokoStandards-Template-Joomla-Plugin RC'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/tag/release-candidate</infourl>
|
|
||||||
<downloads>
|
|
||||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/download/release-candidate/plg_system_yourplugin-01.00.00-rc.zip</downloadurl>
|
|
||||||
</downloads>
|
|
||||||
<tags><tag>rc</tag></tags>
|
|
||||||
<maintainer>Moko Consulting</maintainer>
|
|
||||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
|
||||||
<targetplatform name='joomla' version='(5|6).*'/>
|
|
||||||
<php_minimum>8.1</php_minimum>
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<!-- 5. STABLE -->
|
|
||||||
<update>
|
|
||||||
<name>MokoStandards-Template-Joomla-Plugin</name>
|
|
||||||
<description>MokoStandards-Template-Joomla-Plugin — Moko Consulting extension.</description>
|
|
||||||
<element>plg_system_yourplugin</element>
|
|
||||||
<type>plugin</type>
|
|
||||||
<folder>system</folder>
|
|
||||||
<client>site</client>
|
|
||||||
<version>01.00.02</version>
|
|
||||||
<creationDate>2026-05-02</creationDate>
|
|
||||||
<infourl title='MokoStandards-Template-Joomla-Plugin'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/tag/stable</infourl>
|
|
||||||
<downloads>
|
|
||||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/releases/download/stable/plg_system_yourplugin-01.00.02.zip</downloadurl>
|
|
||||||
</downloads>
|
|
||||||
<tags><tag>stable</tag></tags>
|
|
||||||
<maintainer>Moko Consulting</maintainer>
|
|
||||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
|
||||||
<targetplatform name='joomla' version='(5|6).*'/>
|
|
||||||
<php_minimum>8.1</php_minimum>
|
|
||||||
</update>
|
|
||||||
|
|
||||||
</updates>
|
|
||||||
Reference in New Issue
Block a user