generated from MokoConsulting/Template-Joomla
Compare commits
42 Commits
v1.0.0-dev.1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a713a44949 | |||
| 11da5b8914 | |||
| 076a2d5a27 | |||
| 1d769e6a78 | |||
| 4d85794f88 | |||
| 6790d87f28 | |||
| 16bcef3404 | |||
| 1e83888b84 | |||
| d2503101cd | |||
| a7ae6b9af0 | |||
| 313d8659e0 | |||
| 74a5473c14 | |||
| dc2c9f5f2b | |||
| 97bd5610c1 | |||
| 7aff833d01 | |||
| bdfdfec034 | |||
| 70e542a4d1 | |||
| 445f2b03ba | |||
| 0bb71a80f8 | |||
| 7cdc4d96e4 | |||
| eb9d2a277d | |||
| c2788dc2ac | |||
| 162675d577 | |||
| 4407605372 | |||
| b0c9c0230d | |||
| 984f0b9a1d | |||
| ae5fddcc79 | |||
| da80e6c54e | |||
| c96f0754ca | |||
| 71096244fe | |||
| 8438be07df | |||
| b20212b400 | |||
| 9f16b73572 | |||
| e544482e4e | |||
| 85f094fa8e | |||
| 5ad3eb9f88 | |||
| 2eecbdc93c | |||
| fabf93554a | |||
| b28fa9d7dc | |||
| 206d195564 | |||
| bc7149d270 | |||
| 1987adf3fa |
@@ -1,9 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
MokoStandards Repository Manifest
|
||||
Template: Joomla Extension
|
||||
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
|
||||
-->
|
||||
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
|
||||
<identity>
|
||||
<name>MokoDoliJoomShop</name>
|
||||
<name>Template-Joomla</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Joomla storefront component backed by Dolibarr products and invoicing</description>
|
||||
<description>Template repository for Joomla extensions (plugins, modules, components, templates)</description>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
@@ -13,7 +18,7 @@
|
||||
</governance>
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>joomla-component</package-type>
|
||||
<package-type>joomla-extension</package-type>
|
||||
<entry-point>src/</entry-point>
|
||||
</build>
|
||||
</moko-platform>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/cascade-dev.yml.template
|
||||
# VERSION: 02.00.00
|
||||
# BRIEF: Forward-merge main → all open branches after every push to main
|
||||
#
|
||||
# +========================================================================+
|
||||
# | CASCADE MAIN → ALL BRANCHES |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Triggers on every push to main (PR merges, bot commits, etc.) |
|
||||
# | |
|
||||
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
|
||||
# | 2. For each: create PR (main → branch), auto-merge if clean |
|
||||
# | 3. On conflict: leave PR open for manual resolution |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Cascade Main → Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
cascade:
|
||||
name: Cascade main → branches
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
||||
|
||||
steps:
|
||||
- name: Discover target branches
|
||||
id: branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Fetch all branches (paginated)
|
||||
PAGE=1
|
||||
ALL_BRANCHES=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
||||
TARGETS=""
|
||||
for BRANCH in $ALL_BRANCHES; do
|
||||
case "$BRANCH" in
|
||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
||||
TARGETS="$TARGETS $BRANCH"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
||||
|
||||
if [ -z "$TARGETS" ]; then
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No cascade target branches found"
|
||||
else
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$TARGETS" | wc -w)
|
||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
||||
fi
|
||||
|
||||
- name: Cascade to all target branches
|
||||
if: steps.branches.outputs.targets != ''
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
||||
|
||||
SUCCESS=0
|
||||
CONFLICTS=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
for BRANCH in $TARGETS; do
|
||||
echo ""
|
||||
echo "═══ main → ${BRANCH} ═══"
|
||||
|
||||
# Check if branch is already up to date
|
||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
||||
RESPONSE=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
||||
|
||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
||||
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo " ✅ Already up to date"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
||||
|
||||
# Check for existing cascade PR
|
||||
EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
||||
|
||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
||||
PR_NUMBER=""
|
||||
|
||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
||||
else
|
||||
# Create cascade PR
|
||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
||||
\"head\": \"main\",
|
||||
\"base\": \"${BRANCH}\"
|
||||
}" \
|
||||
"${API}/pulls")
|
||||
|
||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ✅ Created PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
# Try auto-merge
|
||||
PR_DATA=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
|
||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
||||
|
||||
if [ "$MERGEABLE" != "true" ]; then
|
||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"Do\": \"merge\",
|
||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
||||
\"delete_branch_after_merge\": false
|
||||
}" \
|
||||
"${API}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
||||
|
||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Merged: ${SUCCESS}"
|
||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,224 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
alpha/*|beta/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc/*)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Parse manifest for platform detection
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Changelog Gate ────────────────────────────────────────────────────
|
||||
changelog:
|
||||
name: Changelog Updated
|
||||
runs-on: ubuntu-latest
|
||||
if: github.base_ref == 'main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check CHANGELOG.md was updated
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
if git diff --name-only "$BASE" "$HEAD" | grep -q "^CHANGELOG.md$"; then
|
||||
echo "CHANGELOG.md updated"
|
||||
else
|
||||
# Allow [skip changelog] in PR title or body
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_BODY="${{ github.event.pull_request.body }}"
|
||||
if echo "$PR_TITLE $PR_BODY" | grep -qi "\[skip changelog\]"; then
|
||||
echo "::warning::Changelog skip requested via [skip changelog]"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::CHANGELOG.md must be updated before merging to main. Add [skip changelog] to the PR title to bypass."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,386 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Parse .manifest.xml via manifest_read.php — outputs all fields to GITHUB_OUTPUT
|
||||
php /tmp/mokostandards-api/cli/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
# entry-point from manifest, find as fallback
|
||||
MOD_FILE=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field entry-point 2>/dev/null)
|
||||
[ -z "$MOD_FILE" ] && MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve metadata
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Read and bump patch version (with rollover)
|
||||
CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
||||
[ -z "$CURRENT" ] && CURRENT="00.00.00"
|
||||
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
|
||||
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
|
||||
NEW_PATCH=$((10#$PATCH + 1))
|
||||
NEW_MINOR=$((10#$MINOR))
|
||||
NEW_MAJOR=$((10#$MAJOR))
|
||||
|
||||
if [ $NEW_PATCH -gt 99 ]; then
|
||||
NEW_PATCH=0
|
||||
NEW_MINOR=$((NEW_MINOR + 1))
|
||||
fi
|
||||
if [ $NEW_MINOR -gt 99 ]; then
|
||||
NEW_MINOR=0
|
||||
NEW_MAJOR=$((NEW_MAJOR + 1))
|
||||
fi
|
||||
|
||||
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
|
||||
|
||||
# Update README.md
|
||||
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
|
||||
|
||||
# Update platform-specific manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
|
||||
fi
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element (platform-aware)
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
EXT_ELEMENT=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
;;
|
||||
esac
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::error::No src/ or htdocs/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p build/package
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
|
||||
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
|
||||
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
stability = os.environ["PY_STABILITY"]
|
||||
version = os.environ["PY_VERSION"]
|
||||
sha256 = os.environ["PY_SHA256"]
|
||||
zip_name = os.environ["PY_ZIP_NAME"]
|
||||
tag = os.environ["PY_TAG"]
|
||||
date = os.environ["PY_DATE"]
|
||||
gitea_org = os.environ["PY_GITEA_ORG"]
|
||||
gitea_repo = os.environ["PY_GITEA_REPO"]
|
||||
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
|
||||
|
||||
with open("updates.xml", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Map stability to XML tag name
|
||||
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
|
||||
xml_tag = tag_map.get(stability, stability)
|
||||
|
||||
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
block = match.group(1)
|
||||
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
|
||||
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
|
||||
if "<sha256>" in updated:
|
||||
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
|
||||
else:
|
||||
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
|
||||
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
|
||||
content = content.replace(block, updated)
|
||||
print(f"Updated {xml_tag} channel: version={version}")
|
||||
else:
|
||||
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
# Commit and push to current branch
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
|
||||
# Sync updates.xml to main and dev (whichever isn't current)
|
||||
for BRANCH in main dev; do
|
||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||
|
||||
echo "Syncing updates.xml → ${BRANCH}"
|
||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
|
||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
|
||||
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
|
||||
case "$STABILITY" in
|
||||
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
|
||||
beta) TAGS_TO_DELETE="alpha development" ;;
|
||||
alpha) TAGS_TO_DELETE="development" ;;
|
||||
*) TAGS_TO_DELETE="" ;;
|
||||
esac
|
||||
|
||||
[ -z "$TAGS_TO_DELETE" ] && exit 0
|
||||
|
||||
for TAG in $TAGS_TO_DELETE; do
|
||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,766 @@
|
||||
# ============================================================================
|
||||
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# This file is part of a Moko Consulting project.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Joomla: Repo Health"
|
||||
|
||||
concurrency:
|
||||
group: repo-health-${{ github.repository }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
|
||||
# Extended checks toggles
|
||||
EXTENDED_CHECKS: "true"
|
||||
|
||||
# File / directory variables
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
access_check:
|
||||
name: Access control
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
outputs:
|
||||
allowed: ${{ steps.perm.outputs.allowed }}
|
||||
permission: ${{ steps.perm.outputs.permission }}
|
||||
|
||||
steps:
|
||||
- name: Check actor permission (admin only)
|
||||
id: perm
|
||||
env:
|
||||
TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ALLOWED=false
|
||||
PERMISSION=unknown
|
||||
METHOD=""
|
||||
|
||||
# Hardcoded authorized users — always allowed
|
||||
case "$ACTOR" in
|
||||
jmiller|gitea-actions[bot])
|
||||
ALLOWED=true
|
||||
PERMISSION=admin
|
||||
METHOD="hardcoded allowlist"
|
||||
;;
|
||||
*)
|
||||
# Detect platform and check permissions via API
|
||||
API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
|
||||
RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
|
||||
PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
|
||||
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
|
||||
ALLOWED=true
|
||||
fi
|
||||
METHOD="collaborator API"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "## Access Authorization"
|
||||
echo ""
|
||||
echo "| Field | Value |"
|
||||
echo "|-------|-------|"
|
||||
echo "| **Actor** | \`${ACTOR}\` |"
|
||||
echo "| **Repository** | \`${REPO}\` |"
|
||||
echo "| **Permission** | \`${PERMISSION}\` |"
|
||||
echo "| **Method** | ${METHOD} |"
|
||||
echo "| **Authorized** | ${ALLOWED} |"
|
||||
echo ""
|
||||
if [ "$ALLOWED" = "true" ]; then
|
||||
echo "${ACTOR} authorized (${METHOD})"
|
||||
else
|
||||
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
|
||||
fi
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Deny execution when not permitted
|
||||
if: ${{ steps.perm.outputs.allowed != 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scripts folder checks
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes scripts governance'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "${SCRIPT_DIR}" ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' 'Status: OK (advisory)'
|
||||
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
|
||||
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
|
||||
|
||||
missing_dirs=()
|
||||
unapproved_dirs=()
|
||||
|
||||
for d in "${required_dirs[@]}"; do
|
||||
req="${d%/}"
|
||||
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
|
||||
done
|
||||
|
||||
while IFS= read -r d; do
|
||||
allowed=false
|
||||
for a in "${allowed_dirs[@]}"; do
|
||||
a_norm="${a%/}"
|
||||
[ "${d%/}" = "${a_norm}" ] && allowed=true
|
||||
done
|
||||
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
|
||||
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
|
||||
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Area | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
|
||||
if [ "${#missing_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
|
||||
else
|
||||
printf '%s\n' '| Required directories | OK | All required subfolders present |'
|
||||
fi
|
||||
|
||||
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
|
||||
else
|
||||
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
|
||||
fi
|
||||
|
||||
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
|
||||
printf '\n'
|
||||
|
||||
if [ "${#missing_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' 'Missing required script directories:'
|
||||
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
else
|
||||
printf '%s\n' 'Missing required script directories: none.'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' 'Unapproved script directories detected:'
|
||||
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
else
|
||||
printf '%s\n' 'Unapproved script directories detected: none.'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Scripts governance completed in advisory mode.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
repo_health:
|
||||
name: Repository health
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Repository health checks
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes repository health'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid)
|
||||
if [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
if printf '%s' "${item}" | grep -q '/$'; then
|
||||
d="${item%/}"
|
||||
[ ! -d "${d}" ] && missing_required+=("${item}")
|
||||
else
|
||||
[ ! -f "${item}" ] && missing_required+=("${item}")
|
||||
fi
|
||||
done
|
||||
|
||||
for f in "${optional_files[@]}"; do
|
||||
if printf '%s' "${f}" | grep -q '/$'; then
|
||||
d="${f%/}"
|
||||
[ ! -d "${d}" ] && missing_optional+=("${f}")
|
||||
else
|
||||
[ ! -f "${f}" ] && missing_optional+=("${f}")
|
||||
fi
|
||||
done
|
||||
|
||||
for d in "${disallowed_dirs[@]}"; do
|
||||
d_norm="${d%/}"
|
||||
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
|
||||
done
|
||||
|
||||
for f in "${disallowed_files[@]}"; do
|
||||
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
|
||||
done
|
||||
|
||||
git fetch origin --prune
|
||||
|
||||
dev_paths=()
|
||||
dev_branches=()
|
||||
|
||||
while IFS= read -r b; do
|
||||
name="${b#origin/}"
|
||||
if [ "${name}" = 'dev' ]; then
|
||||
dev_branches+=("${name}")
|
||||
else
|
||||
dev_paths+=("${name}")
|
||||
fi
|
||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||
|
||||
if [ "${#dev_paths[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
|
||||
fi
|
||||
|
||||
if [ "${#dev_branches[@]}" -gt 0 ]; then
|
||||
missing_required+=("invalid branch dev (must be dev/<version>)")
|
||||
fi
|
||||
|
||||
content_warnings=()
|
||||
|
||||
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
|
||||
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
|
||||
fi
|
||||
|
||||
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
|
||||
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
|
||||
fi
|
||||
|
||||
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
|
||||
content_warnings+=("LICENSE does not look like a GPL text")
|
||||
fi
|
||||
|
||||
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
|
||||
content_warnings+=("README.md missing expected brand keyword")
|
||||
fi
|
||||
|
||||
export PROFILE_RAW="${profile}"
|
||||
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
|
||||
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
||||
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
||||
|
||||
report_json="$(python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
profile = os.environ.get('PROFILE_RAW') or 'all'
|
||||
|
||||
missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
|
||||
missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
|
||||
content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
|
||||
|
||||
out = {
|
||||
'profile': profile,
|
||||
'missing_required': [x for x in missing_required if x],
|
||||
'missing_optional': [x for x in missing_optional if x],
|
||||
'content_warnings': [x for x in content_warnings if x],
|
||||
}
|
||||
|
||||
print(json.dumps(out, indent=2))
|
||||
PY
|
||||
)"
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Metric | Value |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
|
||||
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
|
||||
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
|
||||
printf '\n'
|
||||
|
||||
printf '%s\n' '### Guardrails report (JSON)'
|
||||
printf '%s\n' '```json'
|
||||
printf '%s\n' "${report_json}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_required[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repo artifacts'
|
||||
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repo artifacts'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#content_warnings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Repo content warnings'
|
||||
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# -- Joomla-specific checks --
|
||||
joomla_findings=()
|
||||
|
||||
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
|
||||
if [ -z "${MANIFEST}" ]; then
|
||||
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
|
||||
else
|
||||
if ! grep -qP '<version>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <version> tag missing")
|
||||
fi
|
||||
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: type attribute missing or invalid")
|
||||
fi
|
||||
if ! grep -qP '<name>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <name> tag missing")
|
||||
fi
|
||||
if ! grep -qP '<author>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <author> tag missing")
|
||||
fi
|
||||
if ! grep -qP '<namespace' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
|
||||
fi
|
||||
fi
|
||||
|
||||
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
|
||||
if [ "${INI_COUNT}" -eq 0 ]; then
|
||||
joomla_findings+=("No .ini language files found")
|
||||
fi
|
||||
|
||||
if [ ! -f 'updates.xml' ]; then
|
||||
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
||||
fi
|
||||
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Joomla extension checks'
|
||||
printf '%s\n' '| Check | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
for f in "${joomla_findings[@]}"; do
|
||||
printf '%s\n' "| ${f} | Warning |"
|
||||
done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
else
|
||||
{
|
||||
printf '%s\n' '### Joomla extension checks'
|
||||
printf '%s\n' 'All Joomla-specific checks passed.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
extended_enabled="${EXTENDED_CHECKS:-true}"
|
||||
extended_findings=()
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
|
||||
:
|
||||
else
|
||||
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
|
||||
fi
|
||||
|
||||
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
|
||||
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
|
||||
if [ -n "${bad_refs}" ]; then
|
||||
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
|
||||
{
|
||||
printf '%s\n' '### Workflow pinning advisory'
|
||||
printf '%s\n' 'Found uses: entries pinned to main/master:'
|
||||
printf '%s\n' '```'
|
||||
printf '%s\n' "${bad_refs}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "${DOCS_INDEX}" ]; then
|
||||
missing_links="$(python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
|
||||
base = os.getcwd()
|
||||
|
||||
bad = []
|
||||
pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
|
||||
|
||||
with open(idx, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
for m in pat.findall(line):
|
||||
link = m.strip()
|
||||
if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
|
||||
continue
|
||||
if link.startswith('/'):
|
||||
rel = link.lstrip('/')
|
||||
else:
|
||||
rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
|
||||
rel = rel.split('#', 1)[0]
|
||||
rel = rel.split('?', 1)[0]
|
||||
if not rel:
|
||||
continue
|
||||
p = os.path.join(base, rel)
|
||||
if not os.path.exists(p):
|
||||
bad.append(rel)
|
||||
|
||||
print('\n'.join(sorted(set(bad))))
|
||||
PY
|
||||
)"
|
||||
if [ -n "${missing_links}" ]; then
|
||||
extended_findings+=("docs/docs-index.md contains broken relative links")
|
||||
{
|
||||
printf '%s\n' '### Docs index link integrity'
|
||||
printf '%s\n' 'Broken relative links:'
|
||||
while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -d "${SCRIPT_DIR}" ]; then
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y shellcheck >/dev/null
|
||||
fi
|
||||
|
||||
sc_out=''
|
||||
while IFS= read -r shf; do
|
||||
[ -z "${shf}" ] && continue
|
||||
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
|
||||
if [ -n "${out_one}" ]; then
|
||||
sc_out="${sc_out}${out_one}\n"
|
||||
fi
|
||||
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
|
||||
|
||||
if [ -n "${sc_out}" ]; then
|
||||
extended_findings+=("ShellCheck warnings detected (advisory)")
|
||||
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
|
||||
{
|
||||
printf '%s\n' '### ShellCheck (advisory)'
|
||||
printf '%s\n' '```'
|
||||
printf '%s\n' "${sc_head}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
spdx_missing=()
|
||||
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
|
||||
spdx_args=()
|
||||
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -z "${f}" ] && continue
|
||||
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
|
||||
spdx_missing+=("${f}")
|
||||
fi
|
||||
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
|
||||
|
||||
if [ "${#spdx_missing[@]}" -gt 0 ]; then
|
||||
extended_findings+=("SPDX header missing in some tracked files (advisory)")
|
||||
{
|
||||
printf '%s\n' '### SPDX header advisory'
|
||||
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
|
||||
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
stale_cutoff_days=180
|
||||
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
|
||||
if [ -n "${stale_branches}" ]; then
|
||||
extended_findings+=("Stale remote branches detected (advisory)")
|
||||
{
|
||||
printf '%s\n' '### Git hygiene advisory'
|
||||
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
|
||||
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Guardrails coverage matrix'
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
if [ "${#extended_findings[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
|
||||
else
|
||||
printf '%s\n' '| Extended checks | OK | No findings |'
|
||||
fi
|
||||
else
|
||||
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
|
||||
fi
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Extended findings (advisory)'
|
||||
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
@@ -0,0 +1,464 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Joomla
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/update-server.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
|
||||
#
|
||||
# Writes updates.xml with multiple <update> entries:
|
||||
# - <tag>stable</tag> on push to main (from auto-release)
|
||||
# - <tag>rc</tag> on push to rc/**
|
||||
# - <tag>development</tag> on push to dev or dev/**
|
||||
#
|
||||
# Joomla filters by user's "Minimum Stability" setting.
|
||||
|
||||
name: "Joomla: Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Stability tag'
|
||||
required: true
|
||||
default: 'development'
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- stable
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update updates.xml
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Generate updates.xml entry
|
||||
id: update
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Auto-bump patch on all branches (dev, alpha, beta, rc)
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
|
||||
if [ -n "$BUMPED" ]; then
|
||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
|
||||
git add -A
|
||||
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
|
||||
git push 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Determine stability from branch or input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
STABILITY="rc"
|
||||
elif [[ "$BRANCH" == beta/* ]]; then
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
||||
STABILITY="development"
|
||||
else
|
||||
STABILITY="stable"
|
||||
fi
|
||||
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Parse manifest (portable — no grep -P)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No Joomla manifest found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract fields using sed (works on all runners)
|
||||
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
|
||||
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
|
||||
|
||||
# Fallbacks
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
|
||||
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
|
||||
|
||||
# Derive element if not in manifest: try XML filename, then repo name
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Use manifest version if README version is empty
|
||||
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
|
||||
|
||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
|
||||
|
||||
CLIENT_TAG=""
|
||||
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
||||
|
||||
FOLDER_TAG=""
|
||||
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||
|
||||
PHP_TAG=""
|
||||
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
||||
|
||||
# Version suffix for non-stable
|
||||
DISPLAY_VERSION="$VERSION"
|
||||
case "$STABILITY" in
|
||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
||||
esac
|
||||
|
||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||
|
||||
# Each stability level has its own release tag
|
||||
case "$STABILITY" in
|
||||
development) RELEASE_TAG="development" ;;
|
||||
alpha) RELEASE_TAG="alpha" ;;
|
||||
beta) RELEASE_TAG="beta" ;;
|
||||
rc) RELEASE_TAG="release-candidate" ;;
|
||||
*) RELEASE_TAG="v${MAJOR}" ;;
|
||||
esac
|
||||
|
||||
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# -- Build install packages (ZIP + tar.gz) --------------------
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ -d "$SOURCE_DIR" ]; then
|
||||
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
|
||||
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
|
||||
|
||||
cd "$SOURCE_DIR"
|
||||
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
|
||||
cd ..
|
||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
|
||||
--exclude='.ftpignore' --exclude='sftp-config*' \
|
||||
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
||||
|
||||
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Ensure release exists on Gitea
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
# Create release
|
||||
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/releases" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'tag_name': '${RELEASE_TAG}',
|
||||
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
|
||||
'body': '${STABILITY} release',
|
||||
'prerelease': True,
|
||||
'target_commitish': 'main'
|
||||
}))")" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
# Delete existing assets with same name before uploading
|
||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
||||
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
|
||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
||||
import sys,json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '${ASSET_FILE}':
|
||||
print(a['id']); break
|
||||
" 2>/dev/null || true)
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Upload both formats
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${PACKAGE_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${TAR_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
SHA256=""
|
||||
fi
|
||||
|
||||
# -- Build the new entry (canonical format matching release.yml) --
|
||||
NEW_ENTRY=""
|
||||
NEW_ENTRY="${NEW_ENTRY} <update>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
|
||||
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
|
||||
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
|
||||
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
|
||||
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </update>"
|
||||
|
||||
# -- Write new entry to temp file --------------------------------
|
||||
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
|
||||
|
||||
# -- Merge into updates.xml ----------------------------------------
|
||||
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
|
||||
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
|
||||
TARGETS=""
|
||||
for entry in $CASCADE_MAP; do
|
||||
key="${entry%%:*}"
|
||||
vals="${entry#*:}"
|
||||
if [ "$key" = "${STABILITY}" ]; then
|
||||
TARGETS="$vals"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
|
||||
|
||||
echo "Cascade: ${STABILITY} → ${TARGETS}"
|
||||
|
||||
# Create updates.xml if missing
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
|
||||
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
|
||||
printf '%s\n' "<updates>" >> updates.xml
|
||||
printf '%s\n' "</updates>" >> updates.xml
|
||||
fi
|
||||
|
||||
# Update existing blocks or create missing ones
|
||||
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
targets = os.environ["PY_TARGETS"].split(",")
|
||||
version = os.environ["PY_VERSION"]
|
||||
date = os.environ["PY_DATE"]
|
||||
|
||||
with open("updates.xml") as f:
|
||||
content = f.read()
|
||||
with open("/tmp/new_entry.xml") as f:
|
||||
new_entry_template = f.read()
|
||||
|
||||
for tag in targets:
|
||||
tag = tag.strip()
|
||||
# Build entry with this tag's name
|
||||
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
|
||||
|
||||
# Try to find existing block (handles both single-line and multi-line <tags>)
|
||||
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
|
||||
match = re.search(block_pattern, content, re.DOTALL)
|
||||
|
||||
if match:
|
||||
# Update in place — replace entire block
|
||||
content = content.replace(match.group(1), new_entry.strip())
|
||||
print(f" UPDATED: <tag>{tag}</tag> → {version}")
|
||||
else:
|
||||
# Create — insert before </updates>
|
||||
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
|
||||
print(f" CREATED: <tag>{tag}</tag> → {version}")
|
||||
|
||||
# Clean up excessive blank lines
|
||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
# Commit
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/contents/updates.xml" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'content': '${CONTENT}',
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
||||
'branch': 'main'
|
||||
}))")" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
||||
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# -- Permission check: admin or maintain role required --------
|
||||
ACTOR="${{ github.actor }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,251 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/branch-protection.yml
|
||||
# BRIEF: Apply standardised branch protection rules to all governed repositories
|
||||
#
|
||||
# +========================================================================+
|
||||
# | BRANCH PROTECTION SETUP |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Applies protection rules for: main, dev, rc, beta, alpha |
|
||||
# | |
|
||||
# | main — Require PR, block rejected reviews, no force push |
|
||||
# | dev — Allow push, no force push, no delete |
|
||||
# | rc — Allow push, no force push, no delete |
|
||||
# | beta — Allow push, no force push, no delete |
|
||||
# | alpha — Allow push, no force push, no delete |
|
||||
# | |
|
||||
# | jmiller has override authority on all branches. |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: Branch Protection Setup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Preview mode (no changes)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
repos:
|
||||
description: 'Comma-separated repo names (empty = all governed repos)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.mokoconsulting.tech
|
||||
GITEA_ORG: MokoConsulting
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
protect:
|
||||
name: Apply Branch Protection Rules
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Determine target repos
|
||||
id: repos
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
|
||||
# Platform/standards/infra repos to exclude
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||
|
||||
if [ -n "${{ inputs.repos }}" ]; then
|
||||
# User-specified repos
|
||||
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
|
||||
else
|
||||
# Fetch all org repos
|
||||
PAGE=1
|
||||
REPOS=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
REPOS="$REPOS $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter out excluded repos
|
||||
FILTERED=""
|
||||
for REPO in $REPOS; do
|
||||
SKIP=false
|
||||
for EX in $EXCLUDE; do
|
||||
if [ "$REPO" = "$EX" ]; then
|
||||
SKIP=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$SKIP" = "false" ]; then
|
||||
FILTERED="$FILTERED $REPO"
|
||||
fi
|
||||
done
|
||||
REPOS="$FILTERED"
|
||||
fi
|
||||
|
||||
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$REPOS" | wc -w)
|
||||
echo "📋 Target repos (${COUNT}): $REPOS"
|
||||
|
||||
- name: Apply protection rules
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
REPOS="${{ steps.repos.outputs.repos }}"
|
||||
|
||||
SUCCESS=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
# ── Rule definitions ──────────────────────────────────────
|
||||
# Only the CI bot (jmiller token) can push directly.
|
||||
# All human contributors must use PRs.
|
||||
# Force push disabled on all branches.
|
||||
|
||||
RULE_MAIN='{
|
||||
"rule_name": "main",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": true,
|
||||
"block_on_rejected_reviews": true,
|
||||
"block_on_outdated_branch": false,
|
||||
"priority": 1
|
||||
}'
|
||||
|
||||
RULE_DEV='{
|
||||
"rule_name": "dev",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 2
|
||||
}'
|
||||
|
||||
RULE_RC='{
|
||||
"rule_name": "rc",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 3
|
||||
}'
|
||||
|
||||
RULE_BETA='{
|
||||
"rule_name": "beta",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 4
|
||||
}'
|
||||
|
||||
RULE_ALPHA='{
|
||||
"rule_name": "alpha",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
"priority": 5
|
||||
}'
|
||||
|
||||
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
|
||||
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
|
||||
|
||||
# ── Apply rules to each repo ──────────────────────────────
|
||||
for REPO in $REPOS; do
|
||||
echo ""
|
||||
echo "═══ ${REPO} ═══"
|
||||
|
||||
for i in "${!RULES[@]}"; do
|
||||
RULE="${RULES[$i]}"
|
||||
NAME="${RULE_NAMES[$i]}"
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY RUN] Would apply rule: ${NAME}"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Delete existing rule if present (idempotent recreate)
|
||||
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
|
||||
curl -sS -o /dev/null -w "" \
|
||||
-X DELETE \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
|
||||
|
||||
# Create rule
|
||||
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RULE" \
|
||||
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
|
||||
|
||||
HTTP=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [ "$HTTP" = "201" ]; then
|
||||
echo " ✅ ${NAME}"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Success: ${SUCCESS}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo " ⏭️ Skipped: ${SKIPPED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "::warning::${FAILED} rule(s) failed to apply"
|
||||
fi
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
+270
-1046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
|
||||
name: "Branch Cleanup"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Delete merged branch
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref != 'dev' &&
|
||||
github.event.pull_request.head.ref != 'main'
|
||||
|
||||
steps:
|
||||
- name: Delete source branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "$STATUS" = "404" ]; then
|
||||
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
@@ -1,213 +1,10 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/cascade-dev.yml.template
|
||||
# VERSION: 02.00.00
|
||||
# BRIEF: Forward-merge main → all open branches after every push to main
|
||||
#
|
||||
# +========================================================================+
|
||||
# | CASCADE MAIN → ALL BRANCHES |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Triggers on every push to main (PR merges, bot commits, etc.) |
|
||||
# | |
|
||||
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
|
||||
# | 2. For each: create PR (main → branch), auto-merge if clean |
|
||||
# | 3. On conflict: leave PR open for manual resolution |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Cascade Main → Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||
name: "Cascade Main → Dev (DISABLED)"
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
cascade:
|
||||
name: Cascade main → branches
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
||||
|
||||
steps:
|
||||
- name: Discover target branches
|
||||
id: branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Fetch all branches (paginated)
|
||||
PAGE=1
|
||||
ALL_BRANCHES=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/branches?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
||||
TARGETS=""
|
||||
for BRANCH in $ALL_BRANCHES; do
|
||||
case "$BRANCH" in
|
||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
||||
TARGETS="$TARGETS $BRANCH"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
||||
|
||||
if [ -z "$TARGETS" ]; then
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No cascade target branches found"
|
||||
else
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$TARGETS" | wc -w)
|
||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
||||
fi
|
||||
|
||||
- name: Cascade to all target branches
|
||||
if: steps.branches.outputs.targets != ''
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
||||
|
||||
SUCCESS=0
|
||||
CONFLICTS=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
for BRANCH in $TARGETS; do
|
||||
echo ""
|
||||
echo "═══ main → ${BRANCH} ═══"
|
||||
|
||||
# Check if branch is already up to date
|
||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
||||
RESPONSE=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
||||
|
||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
||||
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo " ✅ Already up to date"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
||||
|
||||
# Check for existing cascade PR
|
||||
EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
||||
|
||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
||||
PR_NUMBER=""
|
||||
|
||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
||||
else
|
||||
# Create cascade PR
|
||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
||||
\"head\": \"main\",
|
||||
\"base\": \"${BRANCH}\"
|
||||
}" \
|
||||
"${API}/pulls")
|
||||
|
||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ✅ Created PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
# Try auto-merge
|
||||
PR_DATA=$(curl -sS \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
|
||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
||||
|
||||
if [ "$MERGEABLE" != "true" ]; then
|
||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"Do\": \"merge\",
|
||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
||||
\"delete_branch_after_merge\": false
|
||||
}" \
|
||||
"${API}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
||||
|
||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Merged: ${SUCCESS}"
|
||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
create-branch:
|
||||
name: Create feature branch
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
# Build slug from title: lowercase, replace non-alnum with dash, trim
|
||||
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
|
||||
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
|
||||
|
||||
# Check dev branch exists
|
||||
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API}/branches/dev" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${DEV_EXISTS}" != "200" ]; then
|
||||
echo "No dev branch -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create branch from dev
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/branches" \
|
||||
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "${HTTP}" = "201" ]; then
|
||||
echo "Created branch: ${BRANCH}"
|
||||
|
||||
# Comment on issue with branch link
|
||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/comments" \
|
||||
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
|
||||
|
||||
echo "Commented on issue #${ISSUE_NUM}"
|
||||
else
|
||||
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
|
||||
fi
|
||||
+264
-224
@@ -1,224 +1,264 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
alpha/*|beta/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc/*)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Parse manifest for platform detection
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/manifest_read.php --path . --field platform 2>/dev/null)
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Changelog Gate ────────────────────────────────────────────────────
|
||||
changelog:
|
||||
name: Changelog Updated
|
||||
runs-on: ubuntu-latest
|
||||
if: github.base_ref == 'main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check CHANGELOG.md was updated
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
if git diff --name-only "$BASE" "$HEAD" | grep -q "^CHANGELOG.md$"; then
|
||||
echo "CHANGELOG.md updated"
|
||||
else
|
||||
# Allow [skip changelog] in PR title or body
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_BODY="${{ github.event.pull_request.body }}"
|
||||
if echo "$PR_TITLE $PR_BODY" | grep -qi "\[skip changelog\]"; then
|
||||
echo "::warning::Changelog skip requested via [skip changelog]"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::CHANGELOG.md must be updated before merging to main. Add [skip changelog] to the PR title to bypass."
|
||||
exit 1
|
||||
fi
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||
report-issues:
|
||||
name: Report Issues
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.validate.result == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issue for PR validation failure"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
./automation/ci-issue-reporter.sh \
|
||||
--gate "PR Validation" \
|
||||
--workflow "PR Check" \
|
||||
--severity error \
|
||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
|
||||
@@ -5,14 +5,18 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -35,41 +39,44 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
- name: Setup PHP
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
tr -d '[:space:]')| tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
# For packages: prefer pkg_*.xml in src/; fallback to any manifest
|
||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
@@ -78,109 +85,44 @@ jobs:
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Read and bump patch version (with rollover)
|
||||
CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
||||
[ -z "$CURRENT" ] && CURRENT="00.00.00"
|
||||
# Read current version (bump already handled by push workflow)
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
# Strip any existing suffix from version before applying stability
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
|
||||
NEW_PATCH=$((10#$PATCH + 1))
|
||||
NEW_MINOR=$((10#$MINOR))
|
||||
NEW_MAJOR=$((10#$MAJOR))
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
|
||||
if [ $NEW_PATCH -gt 99 ]; then
|
||||
NEW_PATCH=0
|
||||
NEW_MINOR=$((NEW_MINOR + 1))
|
||||
# Verify version consistency across all files
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Update VERSION variable with suffix
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
if [ $NEW_MINOR -gt 99 ]; then
|
||||
NEW_MINOR=0
|
||||
NEW_MAJOR=$((NEW_MAJOR + 1))
|
||||
fi
|
||||
|
||||
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
|
||||
|
||||
# Update README.md
|
||||
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
|
||||
|
||||
# Update platform-specific manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
|
||||
fi
|
||||
# For packages: also bump version in all sub-extension manifests
|
||||
if [ -d "src/packages" ]; then
|
||||
for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null); do
|
||||
SUB_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1)
|
||||
if [ -n "$SUB_VER" ]; then
|
||||
sed -i "s|<version>${SUB_VER}</version>|<version>${VERSION}</version>|" "$SUB_MANIFEST"
|
||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$SUB_MANIFEST"
|
||||
echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
|
||||
fi
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element (platform-aware)
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
EXT_ELEMENT=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
;;
|
||||
esac
|
||||
# Auto-detect element via manifest_element.php
|
||||
php ${MOKO_CLI}/manifest_element.php \
|
||||
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||
--repo "${GITEA_REPO}" --github-output
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
# Read back element outputs
|
||||
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
@@ -188,183 +130,52 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::error::No src/ or htdocs/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
||||
EXT_TYPE=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
|
||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
||||
|
||||
mkdir -p build/package
|
||||
|
||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
||||
|
||||
# 1) ZIP each sub-extension in src/packages/
|
||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
||||
[ ! -d "$ext_dir" ] && continue
|
||||
EXT_NAME=$(basename "$ext_dir")
|
||||
echo " Packaging sub-extension: ${EXT_NAME}"
|
||||
cd "$ext_dir"
|
||||
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
|
||||
cd "$OLDPWD"
|
||||
done
|
||||
|
||||
# 2) Copy package-level files (manifest, script, etc.)
|
||||
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
|
||||
[ -f "$f" ] && cp "$f" build/package/
|
||||
done
|
||||
|
||||
echo "Package contents:"
|
||||
ls -la build/package/
|
||||
else
|
||||
echo "=== Building standard Joomla extension ==="
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
fi
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
- name: Create release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
echo "No updates.xml -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
|
||||
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
|
||||
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
stability = os.environ["PY_STABILITY"]
|
||||
version = os.environ["PY_VERSION"]
|
||||
sha256 = os.environ["PY_SHA256"]
|
||||
zip_name = os.environ["PY_ZIP_NAME"]
|
||||
tag = os.environ["PY_TAG"]
|
||||
date = os.environ["PY_DATE"]
|
||||
gitea_org = os.environ["PY_GITEA_ORG"]
|
||||
gitea_repo = os.environ["PY_GITEA_REPO"]
|
||||
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
with open("updates.xml", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Map stability to XML tag name
|
||||
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
|
||||
xml_tag = tag_map.get(stability, stability)
|
||||
|
||||
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
block = match.group(1)
|
||||
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
|
||||
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
|
||||
if "<sha256>" in updated:
|
||||
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
|
||||
else:
|
||||
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
|
||||
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
|
||||
content = content.replace(block, updated)
|
||||
print(f"Updated {xml_tag} channel: version={version}")
|
||||
else:
|
||||
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
# Commit and push to current branch
|
||||
# Commit and push
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
@@ -380,13 +191,11 @@ jobs:
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
|
||||
# Sync updates.xml to main and dev (whichever isn't current)
|
||||
for BRANCH in main dev; do
|
||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||
|
||||
echo "Syncing updates.xml → ${BRANCH}"
|
||||
echo "Syncing updates.xml -> ${BRANCH}"
|
||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
|
||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
@@ -400,29 +209,25 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
|
||||
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
|
||||
case "$STABILITY" in
|
||||
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
|
||||
beta) TAGS_TO_DELETE="alpha development" ;;
|
||||
alpha) TAGS_TO_DELETE="development" ;;
|
||||
*) TAGS_TO_DELETE="" ;;
|
||||
esac
|
||||
|
||||
[ -z "$TAGS_TO_DELETE" ] && exit 0
|
||||
|
||||
for TAG in $TAGS_TO_DELETE; do
|
||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
||||
fi
|
||||
done
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,20 +4,18 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Joomla
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/update-server.yml.template
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
|
||||
# INGROUP: moko-platform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/update-server.yml
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
|
||||
#
|
||||
# Writes updates.xml with multiple <update> entries:
|
||||
# - <tag>stable</tag> on push to main (from auto-release)
|
||||
# - <tag>rc</tag> on push to rc/**
|
||||
# - <tag>development</tag> on push to dev or dev/**
|
||||
# Thin wrapper around moko-platform CLI tools.
|
||||
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
|
||||
#
|
||||
# Joomla filters by user's "Minimum Stability" setting.
|
||||
# Joomla filters update entries by the user's "Minimum Stability" setting.
|
||||
|
||||
name: "Joomla: Update Server"
|
||||
name: "Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -66,55 +64,60 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update updates.xml
|
||||
name: Update Server
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate updates.xml entry
|
||||
id: update
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve stability and bump version
|
||||
id: meta
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Auto-bump patch on all branches (dev, alpha, beta, rc)
|
||||
# Configure git for bot pushes
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
|
||||
if [ -n "$BUMPED" ]; then
|
||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
|
||||
git add -A
|
||||
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
|
||||
git push 2>/dev/null || true
|
||||
fi
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
# Determine stability from branch or input
|
||||
# Auto-bump patch version
|
||||
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
|
||||
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Strip any existing suffix before applying stability
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
# Determine stability from branch or manual input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
@@ -123,277 +126,122 @@ jobs:
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
||||
else
|
||||
STABILITY="development"
|
||||
else
|
||||
STABILITY="stable"
|
||||
fi
|
||||
|
||||
# Version suffix per stability stream
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
*) SUFFIX=""; TAG="stable" ;;
|
||||
esac
|
||||
|
||||
# Propagate version with stability suffix to all manifest files
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Re-read version (now includes suffix from version_set_platform)
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "display_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Parse manifest (portable — no grep -P)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "No Joomla manifest found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract fields using sed (works on all runners)
|
||||
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
||||
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
|
||||
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
|
||||
|
||||
# Fallbacks
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
|
||||
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
|
||||
|
||||
# Derive element if not in manifest: try XML filename, then repo name
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Use manifest version if README version is empty
|
||||
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
|
||||
|
||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
|
||||
|
||||
CLIENT_TAG=""
|
||||
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
||||
|
||||
FOLDER_TAG=""
|
||||
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||
|
||||
PHP_TAG=""
|
||||
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
||||
|
||||
# Version suffix for non-stable
|
||||
DISPLAY_VERSION="$VERSION"
|
||||
case "$STABILITY" in
|
||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
||||
esac
|
||||
|
||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||
|
||||
# Each stability level has its own release tag
|
||||
case "$STABILITY" in
|
||||
development) RELEASE_TAG="development" ;;
|
||||
alpha) RELEASE_TAG="alpha" ;;
|
||||
beta) RELEASE_TAG="beta" ;;
|
||||
rc) RELEASE_TAG="release-candidate" ;;
|
||||
*) RELEASE_TAG="v${MAJOR}" ;;
|
||||
esac
|
||||
|
||||
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# -- Build install packages (ZIP + tar.gz) --------------------
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ -d "$SOURCE_DIR" ]; then
|
||||
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
|
||||
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
|
||||
|
||||
cd "$SOURCE_DIR"
|
||||
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
|
||||
cd ..
|
||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
|
||||
--exclude='.ftpignore' --exclude='sftp-config*' \
|
||||
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
||||
|
||||
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Ensure release exists on Gitea
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
# Create release
|
||||
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/releases" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'tag_name': '${RELEASE_TAG}',
|
||||
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
|
||||
'body': '${STABILITY} release',
|
||||
'prerelease': True,
|
||||
'target_commitish': 'main'
|
||||
}))")" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
# Delete existing assets with same name before uploading
|
||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
||||
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
|
||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
||||
import sys,json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '${ASSET_FILE}':
|
||||
print(a['id']); break
|
||||
" 2>/dev/null || true)
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Upload both formats
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${PACKAGE_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${TAR_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
SHA256=""
|
||||
fi
|
||||
|
||||
# -- Build the new entry (canonical format matching release.yml) --
|
||||
NEW_ENTRY=""
|
||||
NEW_ENTRY="${NEW_ENTRY} <update>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
|
||||
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
|
||||
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
|
||||
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
|
||||
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
|
||||
NEW_ENTRY="${NEW_ENTRY} </update>"
|
||||
|
||||
# -- Write new entry to temp file --------------------------------
|
||||
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
|
||||
|
||||
# -- Merge into updates.xml ----------------------------------------
|
||||
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
|
||||
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
|
||||
TARGETS=""
|
||||
for entry in $CASCADE_MAP; do
|
||||
key="${entry%%:*}"
|
||||
vals="${entry#*:}"
|
||||
if [ "$key" = "${STABILITY}" ]; then
|
||||
TARGETS="$vals"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
|
||||
|
||||
echo "Cascade: ${STABILITY} → ${TARGETS}"
|
||||
|
||||
# Create updates.xml if missing
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
|
||||
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
|
||||
printf '%s\n' "<updates>" >> updates.xml
|
||||
printf '%s\n' "</updates>" >> updates.xml
|
||||
fi
|
||||
|
||||
# Update existing blocks or create missing ones
|
||||
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
targets = os.environ["PY_TARGETS"].split(",")
|
||||
version = os.environ["PY_VERSION"]
|
||||
date = os.environ["PY_DATE"]
|
||||
|
||||
with open("updates.xml") as f:
|
||||
content = f.read()
|
||||
with open("/tmp/new_entry.xml") as f:
|
||||
new_entry_template = f.read()
|
||||
|
||||
for tag in targets:
|
||||
tag = tag.strip()
|
||||
# Build entry with this tag's name
|
||||
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
|
||||
|
||||
# Try to find existing block (handles both single-line and multi-line <tags>)
|
||||
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
|
||||
match = re.search(block_pattern, content, re.DOTALL)
|
||||
|
||||
if match:
|
||||
# Update in place — replace entire block
|
||||
content = content.replace(match.group(1), new_entry.strip())
|
||||
print(f" UPDATED: <tag>{tag}</tag> → {version}")
|
||||
else:
|
||||
# Create — insert before </updates>
|
||||
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
|
||||
print(f" CREATED: <tag>{tag}</tag> → {version}")
|
||||
|
||||
# Clean up excessive blank lines
|
||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
# Commit
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
# Commit version bump if changed
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
||||
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
||||
- name: Create release and upload package
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Create or update Gitea release
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
# Build package and upload
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push updates.xml
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main'
|
||||
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/contents/updates.xml" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'content': '${CONTENT}',
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
||||
'branch': 'main'
|
||||
}))")" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
||||
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
|
||||
python3 -c "
|
||||
import base64, json, urllib.request, sys
|
||||
with open('updates.xml', 'rb') as f:
|
||||
content = base64.b64encode(f.read()).decode()
|
||||
payload = json.dumps({
|
||||
'content': content,
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
|
||||
'branch': 'main'
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/contents/updates.xml',
|
||||
data=payload, method='PUT',
|
||||
headers={
|
||||
'Authorization': 'token ${GITEA_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
print('updates.xml synced to main')
|
||||
except Exception as e:
|
||||
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
|
||||
"
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
@@ -407,12 +255,11 @@ jobs:
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# -- Permission check: admin or maintain role required --------
|
||||
# Permission check: admin or maintain role required
|
||||
ACTOR="${{ github.actor }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
@@ -442,11 +289,11 @@ jobs:
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -454,11 +301,12 @@ jobs:
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
DISPLAY="${{ steps.meta.outputs.display_version }}"
|
||||
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+6
-51
@@ -5,58 +5,13 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0-dev.1] - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Core Storefront (Critical — Issues #1-6)
|
||||
- Product catalog view with Dolibarr API integration, pagination, category filtering
|
||||
- Product detail view with stock status, image gallery, Schema.org JSON-LD, add-to-cart
|
||||
- Session-based shopping cart with DB persistence, quantity update, stock validation
|
||||
- Cart merge on user login (guest → registered)
|
||||
- Checkout flow with billing form, guest and registered modes
|
||||
- Dolibarr order and invoice creation from cart data
|
||||
- Customer sync service: Joomla user ↔ Dolibarr thirdparty mapping with email dedup
|
||||
- Enhanced DolibarrClient with SSL verify, detailed connection test, permission checks
|
||||
- Dashboard connection status with version display, permission indicators, troubleshooting hints
|
||||
|
||||
#### High Priority (Issues #7-12)
|
||||
- Hierarchical product category navigation with sidebar tree and breadcrumbs
|
||||
- Category landing pages with filtered product grid
|
||||
- Stock/inventory display: In Stock / Low Stock / Out of Stock badges
|
||||
- Configurable low-stock threshold and backorder support
|
||||
- Tax calculation from Dolibarr `tva_tx` with grouped tax breakdown
|
||||
- Configurable tax display mode (TTC, HT, or both)
|
||||
- Product search controller with AJAX endpoint, text search, price range, sorting
|
||||
- Order history (My Orders) for registered users with detail view
|
||||
- Admin dashboard with product/order/customer counts, revenue metrics, recent orders
|
||||
|
||||
#### Medium Priority (Issues #13-19)
|
||||
- SEF URL router for all views (clean URLs)
|
||||
- Product image service with local caching, thumbnail support, placeholder fallback
|
||||
- Email notification service: customer confirmation and admin notification on order
|
||||
- Joomla menu item types for Products, Category, Product, Cart, Checkout, My Orders
|
||||
- Responsive storefront CSS (mobile-first, sticky add-to-cart, touch-friendly cart)
|
||||
- Product variant/attribute helper (Dolibarr combinations support)
|
||||
- Admin orders management view with filters (status, date, search) and Dolibarr sync
|
||||
|
||||
#### Low Priority (Issues #20-27)
|
||||
- Wishlist / Save for Later with DB persistence and guest merge on login
|
||||
- Coupon/discount code validation against Dolibarr discount rules
|
||||
- API response caching via Joomla cache framework with configurable TTL
|
||||
- Shipping address management (address book, default address, CRUD)
|
||||
- Joomla ACL integration (component-level permissions for products, orders, settings)
|
||||
- Dolibarr webhook endpoint with event processing and log table
|
||||
- Frontend invoice PDF download (streamed from Dolibarr with ownership check)
|
||||
- Multi-language readiness (all strings via Joomla Text class)
|
||||
|
||||
#### Infrastructure
|
||||
- Database schema: 6 tables (cart, orders, customers, wishlist, addresses, webhook_log)
|
||||
- Component manifest with config fieldsets: Dolibarr, Shop, Performance, Webhooks
|
||||
- Media folder with responsive CSS
|
||||
- Full en-GB language files for admin and site
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Initial component scaffold with Dolibarr REST API client
|
||||
- Admin dashboard with connection status
|
||||
- Admin views: products, orders, customers (placeholders)
|
||||
- Site views: products catalog, product detail, cart, checkout (placeholders)
|
||||
- Database schema for cart, orders, and customer mapping
|
||||
- Component parameters for Dolibarr connection and shop settings
|
||||
- Language files (en-GB)
|
||||
|
||||
@@ -56,6 +56,8 @@ make clean # Clean build artifacts
|
||||
|
||||
## Rules
|
||||
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev/`, merge to `main` for release
|
||||
|
||||
+161
-245
@@ -1,245 +1,161 @@
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
This file is part of a Moko Consulting project.
|
||||
|
||||
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License (./LICENSE).
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: MokoStandards-Template-Joomla-Plugin
|
||||
INGROUP: MokoStandards-Template-Joomla-Plugin.Documentation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin/
|
||||
VERSION: 01.00.00
|
||||
PATH: ./CONTRIBUTING.md
|
||||
BRIEF: How to contribute; commit, PR, testing and security policies
|
||||
NOTE: Template repository - customize for your project
|
||||
-->
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing. This document defines the baseline expectations, workflows, and quality gates for any change entering this repository.
|
||||
|
||||
The objective is to keep contributions predictable, reviewable, and compliant with MokoStandards while enabling a sustainable delivery pipeline.
|
||||
|
||||
## Governance and scope
|
||||
|
||||
This CONTRIBUTING file operates alongside the following governance assets:
|
||||
|
||||
* `README.md` for project overview and onboarding
|
||||
* `LICENSE` for legal terms and reuse constraints
|
||||
* `CODE_OF_CONDUCT.md` for behavioral expectations
|
||||
|
||||
In case of conflict, legal terms in `LICENSE` take precedence, followed by this document.
|
||||
|
||||
## Alignment with MokoStandards
|
||||
|
||||
All Moko Consulting projects are expected to comply with the shared standards defined in the `MokoStandards` repository.
|
||||
|
||||
* Source of truth: [https://github.com/mokoconsulting-tech/MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards)
|
||||
* Areas covered: headers, licensing, coding style, documentation layout, and CI expectations
|
||||
|
||||
Per project policy, this file should reference standards rather than redefining them. Any deviation must be explicitly documented.
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
Contributions are welcome across multiple workstreams:
|
||||
|
||||
* Bug reports and defect reproduction scenarios
|
||||
* Feature requests aligned with the project roadmap
|
||||
* Code changes, including refactors and technical debt reduction
|
||||
* Documentation improvements and clarifications
|
||||
* Test coverage (unit, integration, and regression scenarios)
|
||||
|
||||
Before investing significant effort, contributors are encouraged to review the open issues and roadmap documents to avoid misalignment.
|
||||
|
||||
## Communication channels
|
||||
|
||||
Typical communication paths include:
|
||||
|
||||
* GitHub Issues for bug reports, feature requests, and questions
|
||||
* GitHub Discussions for design conversations, Q&A, and community engagement
|
||||
* Pull Requests for change proposals
|
||||
|
||||
## Issue workflow
|
||||
|
||||
Use GitHub Issues as the system of record for all work.
|
||||
|
||||
When opening an issue:
|
||||
|
||||
1. Search existing issues to avoid duplication.
|
||||
2. Select the appropriate issue template if available.
|
||||
3. Provide a concise, action oriented title.
|
||||
4. Supply a clear description including:
|
||||
|
||||
* Expected behavior
|
||||
* Actual behavior
|
||||
* Minimal steps to reproduce
|
||||
* Environment details (version, platform, configuration)
|
||||
5. Attach logs, screenshots, or configuration snippets as needed, after removing sensitive data.
|
||||
|
||||
Maintainers will triage issues based on impact, risk, and alignment with the roadmap. Not all requests will be accepted, but all will be reviewed in good faith.
|
||||
|
||||
## Pull request workflow
|
||||
|
||||
Pull Requests (PRs) are the primary integration path for changes.
|
||||
|
||||
Standard workflow:
|
||||
|
||||
1. Open or reference an issue describing the problem or enhancement.
|
||||
2. Fork the repository or create a feature branch from the canonical default branch (commonly `main` or `master`).
|
||||
3. Implement changes aligned with the coding standards and file header requirements.
|
||||
4. Add or update tests to validate the behavior.
|
||||
5. Update documentation where behavior, configuration, or interfaces change.
|
||||
6. Run the full test and linting suite locally before opening the PR.
|
||||
7. Open a PR with:
|
||||
|
||||
* A precise, descriptive title
|
||||
* A summary of changes
|
||||
* Explicit linkage to the corresponding issue
|
||||
* Notes on testing performed and any known limitations
|
||||
|
||||
PRs must pass automated checks before they will be considered for review. The maintainer team reserves the right to request revisions, split changes, or defer work that does not align with the current release plan.
|
||||
|
||||
## Merge strategy
|
||||
|
||||
This repository uses **squash merge** as the only permitted merge method for pull requests to the main branch. This ensures a clean, linear git history where each commit represents a complete, reviewed change.
|
||||
|
||||
Key implications for contributors:
|
||||
|
||||
* **PR Title is Important**: The PR title becomes the commit message subject. Make it clear and descriptive.
|
||||
* **PR Description is Important**: The PR description becomes the commit message body. Include rationale and summary of changes.
|
||||
* **Automatic Cleanup**: Branches are automatically deleted after merge.
|
||||
* **No Merge Commits**: Regular merge commits and rebase merges are disabled.
|
||||
|
||||
## Branching and versioning
|
||||
|
||||
Unless specified otherwise:
|
||||
|
||||
* Default development branch: `main`
|
||||
* Feature work: short lived feature branches named using a predictable convention, for example `feature/<short-description>` or `fix/<short-description>`
|
||||
* Releases: tagged using semantic versioning (`MAJOR.MINOR.PATCH`)
|
||||
|
||||
## Coding standards and file headers
|
||||
|
||||
This project adheres to the coding conventions and header rules defined in MokoStandards. At a minimum:
|
||||
|
||||
* All source and configuration files must include the standard SPDX compatible header where applicable.
|
||||
* Language specific style guides (for example PHP, JavaScript, Python) must be followed.
|
||||
* Follow Joomla coding standards for PHP code.
|
||||
|
||||
## Commit message guidelines
|
||||
|
||||
Commit messages are part of the project audit trail. They should be structured and descriptive.
|
||||
|
||||
Recommended format:
|
||||
|
||||
* Short subject line in the imperative mood, for example `Add`, `Fix`, `Refactor`
|
||||
* Optional body that explains the rationale, constraints, and side effects
|
||||
* Reference to related issues using `Fixes #<id>` or `Refs #<id>` as appropriate
|
||||
|
||||
Avoid bundling unrelated changes into a single commit. Small, logically grouped commits improve traceability and rollback options.
|
||||
|
||||
## Testing expectations
|
||||
|
||||
Before opening a PR, contributors are expected to:
|
||||
|
||||
* Run all available automated tests (unit, integration, and other configured checks)
|
||||
* Ensure linting and static analysis pass without new violations
|
||||
* Test changes with a local Joomla installation when applicable
|
||||
|
||||
For new features or non trivial fixes, please include tests that:
|
||||
|
||||
* Reproduce the defect, or
|
||||
* Demonstrate the new behavior
|
||||
|
||||
If tests are not included, the PR should clearly state why (for example, infrastructure limitations, complex external dependencies, or pure documentation changes).
|
||||
|
||||
## Documentation contributions
|
||||
|
||||
Documentation is a first class asset in this ecosystem.
|
||||
|
||||
When contributing documentation:
|
||||
|
||||
* Align with the established docs hierarchy (for example `docs/`)
|
||||
* Apply the shared template structure for new documents
|
||||
* Include appropriate navigation, metadata, and revision history sections when required by the documentation standards
|
||||
|
||||
Minor corrections such as typo fixes are welcome, but larger structural changes should be coordinated via an issue or design note first.
|
||||
|
||||
## Security and responsible disclosure
|
||||
|
||||
Security sensitive issues must not be reported in public issues.
|
||||
|
||||
Use the security contact channel defined in `SECURITY.md` to share details.
|
||||
|
||||
Provide enough information for maintainers to reproduce and understand the impact. The team will coordinate fixes and disclosure timelines as appropriate.
|
||||
|
||||
## License and contributor agreement
|
||||
|
||||
Unless stated otherwise, contributions to this repository are accepted under the same license as the project, GPL 3.0 or later.
|
||||
|
||||
By submitting a contribution, you confirm that:
|
||||
|
||||
* You have the right to contribute the code or content.
|
||||
* You agree that your contribution will be licensed under the project license.
|
||||
* You will not submit content that infringes third party rights.
|
||||
|
||||
## Escalation and decision making
|
||||
|
||||
If you disagree with a review decision or prioritization decision:
|
||||
|
||||
1. Escalate by commenting on the issue or PR with your reasoning.
|
||||
2. Request a second review from a different maintainer.
|
||||
3. Contact the project maintainers directly via the channels listed in README.md.
|
||||
|
||||
Final decisions rest with the project maintainers, but all concerns will be considered fairly.
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
For questions or clarifications about this contributing guide:
|
||||
|
||||
* Open a GitHub Issue
|
||||
* Contact: hello@mokoconsulting.tech
|
||||
|
||||
Thank you for contributing to this project and supporting the MokoStandards ecosystem!
|
||||
|
||||
## Infrastructure Standards
|
||||
|
||||
All repositories in the MokoConsulting org follow these conventions:
|
||||
|
||||
### Release Tags
|
||||
|
||||
Every repo maintains 5 standard release channel tags:
|
||||
|
||||
- `development` - Active development builds
|
||||
- `alpha` - Early internal testing
|
||||
- `beta` - Broader testing / client UAT
|
||||
- `release-candidate` - Final QA before production
|
||||
- `stable` - Production release
|
||||
|
||||
### Branch Protection
|
||||
|
||||
- `main` is protected; only `jmiller` can push directly
|
||||
- All other contributors must use pull requests
|
||||
- PRs are automatically reviewed by Claude Code
|
||||
|
||||
### CI/CD
|
||||
|
||||
- Gitea Actions runs all CI workflows
|
||||
- GitHub Actions are disabled on mirrored repos
|
||||
- Workflows live in both `.github/workflows/` and `.gitea/workflows/`
|
||||
|
||||
### Update Servers (Joomla)
|
||||
|
||||
In manifest `<updateservers>`, Gitea must be priority 1, GitHub priority 2.
|
||||
|
||||
### Secrets
|
||||
|
||||
All repos have `GA_TOKEN` and `GH_TOKEN` as Actions secrets for API access.
|
||||
# Contributing to Moko Consulting Projects
|
||||
|
||||
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
|
||||
|
||||
## Branching Workflow
|
||||
|
||||
```
|
||||
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
|
||||
```
|
||||
|
||||
### Step by step
|
||||
|
||||
1. **Create a feature branch** from `dev`:
|
||||
```bash
|
||||
git checkout dev && git pull
|
||||
git checkout -b feature/my-change
|
||||
```
|
||||
|
||||
2. **Work and commit** on your feature branch. Push to origin.
|
||||
|
||||
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
|
||||
|
||||
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
|
||||
- This automatically renames the source branch to `rc` (release candidate)
|
||||
- An RC pre-release is built and uploaded
|
||||
|
||||
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
|
||||
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
|
||||
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
|
||||
- When the draft PR is created, the branch is renamed to `rc`
|
||||
|
||||
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
|
||||
|
||||
7. **Merging to main** triggers the stable release pipeline:
|
||||
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
|
||||
- Stability suffix stripped (clean version)
|
||||
- Gitea release created with ZIP/tar.gz packages
|
||||
- `updates.xml` updated (Joomla extensions)
|
||||
- `dev` branch recreated from `main`
|
||||
|
||||
### Branch summary
|
||||
|
||||
| Branch | Purpose | Created by |
|
||||
|--------|---------|-----------|
|
||||
| `feature/*` | New features and fixes | Developer |
|
||||
| `dev` | Integration branch | Auto-recreated after release |
|
||||
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
|
||||
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
|
||||
| `rc` | Release candidate | Auto-renamed on draft PR to main |
|
||||
| `main` | Stable releases | Protected, merge only |
|
||||
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
|
||||
|
||||
### Protected branches
|
||||
|
||||
| Branch | Direct push | Merge via |
|
||||
|--------|------------|-----------|
|
||||
| `main` | Blocked (CI bot whitelisted) | PR merge only |
|
||||
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
|
||||
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
|
||||
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `feature/*` | Open | N/A (source branch) |
|
||||
|
||||
## Version Policy
|
||||
|
||||
### Format
|
||||
|
||||
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
|
||||
|
||||
- **XX** — Major version (breaking changes)
|
||||
- **YY** — Minor version (new features, bumped on release to main)
|
||||
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
|
||||
|
||||
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
|
||||
|
||||
### Stability suffixes
|
||||
|
||||
Each branch appends a suffix to indicate stability:
|
||||
|
||||
| Branch | Suffix | Example |
|
||||
|--------|--------|---------|
|
||||
| `main` | (none) | `02.09.00` |
|
||||
| `dev` | `-dev` | `02.09.01-dev` |
|
||||
| `feature/*` | `-dev` | `02.09.01-dev` |
|
||||
| `alpha` | `-alpha` | `02.09.01-alpha` |
|
||||
| `beta` | `-beta` | `02.09.01-beta` |
|
||||
| `rc` | `-rc` | `02.09.01-rc` |
|
||||
|
||||
### Auto version bump
|
||||
|
||||
On every push to `dev`, `feature/*`, or `patch/*`:
|
||||
|
||||
1. Patch version incremented
|
||||
2. Stability suffix `-dev` applied
|
||||
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
|
||||
4. Commit created with `[skip ci]` to avoid loops
|
||||
|
||||
### Release version flow
|
||||
|
||||
Version bumps happen at specific release events:
|
||||
|
||||
| Event | Bump | Example |
|
||||
|-------|------|---------|
|
||||
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
|
||||
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
|
||||
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
|
||||
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
|
||||
|
||||
### Release stream copies
|
||||
|
||||
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
|
||||
|
||||
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
|
||||
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
|
||||
|
||||
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
|
||||
|
||||
### Version files
|
||||
|
||||
The version tools update all files containing version stamps:
|
||||
|
||||
- `.mokogitea/manifest.xml` (canonical source)
|
||||
- Joomla XML manifests (`<version>` tag)
|
||||
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
|
||||
- `package.json`, `pyproject.toml`
|
||||
- Any text file with a `VERSION: XX.YY.ZZ` label
|
||||
|
||||
Files synced from other repos (with a `# REPO:` header) are not touched.
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **PHP**: PSR-12, tabs for indentation
|
||||
- **Copyright**: all files must include the Moko Consulting copyright header
|
||||
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commit format:
|
||||
|
||||
```
|
||||
type(scope): short description
|
||||
|
||||
Optional body with context.
|
||||
|
||||
Authored-by: Moko Consulting
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
|
||||
|
||||
Special flags in commit messages:
|
||||
- `[skip ci]` — skip all CI workflows
|
||||
- `[skip bump]` — skip auto version bump only
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the repository's issue tracker with the appropriate template.
|
||||
|
||||
---
|
||||
|
||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* MokoDoliJoomShop - Responsive Storefront Styles
|
||||
* Mobile-first responsive layout for all storefront views.
|
||||
*
|
||||
* @package MokoDoliJoomShop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Base / Mobile-first (320px+)
|
||||
========================================================================== */
|
||||
|
||||
.com-mokodolijoomshop-products,
|
||||
.com-mokodolijoomshop-product,
|
||||
.com-mokodolijoomshop-cart,
|
||||
.com-mokodolijoomshop-checkout,
|
||||
.com-mokodolijoomshop-category,
|
||||
.com-mokodolijoomshop-orders {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* Product cards */
|
||||
.product-card {
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.product-card .card-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.product-card .card-title a:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Product gallery */
|
||||
.product-gallery img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Cart table mobile */
|
||||
@media (max-width: 575.98px) {
|
||||
.com-mokodolijoomshop-cart .table-responsive table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-cart .table th,
|
||||
.com-mokodolijoomshop-cart .table td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Tablet (576px+)
|
||||
========================================================================== */
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.product-card .card-body {
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Desktop (992px+)
|
||||
========================================================================== */
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.product-card .card-body {
|
||||
min-height: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Mobile Product Detail - Sticky Add to Cart
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.com-mokodolijoomshop-product .input-group {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-product form[action*="cart.add"] {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--bs-body-bg, #fff);
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Checkout form columns stack on mobile */
|
||||
.com-mokodolijoomshop-checkout .row > .col-md-7,
|
||||
.com-mokodolijoomshop-checkout .row > .col-md-5 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Category Sidebar
|
||||
========================================================================== */
|
||||
|
||||
.com-mokodolijoomshop-category .list-group-item a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-category .list-group-item a:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-category .list-group-item.active a {
|
||||
color: var(--bs-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Nested category lists */
|
||||
.com-mokodolijoomshop-category .list-group .list-group {
|
||||
margin-left: 1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Shop Categories Navigation (products view)
|
||||
========================================================================== */
|
||||
|
||||
.shop-categories {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Touch-friendly Cart Controls
|
||||
========================================================================== */
|
||||
|
||||
.com-mokodolijoomshop-cart input[type="number"] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: textfield;
|
||||
text-align: center;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.com-mokodolijoomshop-cart .btn-outline-danger {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Order Status Badges
|
||||
========================================================================== */
|
||||
|
||||
.com-mokodolijoomshop-orders .badge {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Print Styles
|
||||
========================================================================== */
|
||||
|
||||
@media print {
|
||||
.shop-categories,
|
||||
.com-mokodolijoomshop-product form,
|
||||
.com-mokodolijoomshop-cart .btn,
|
||||
.pagination {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -27,66 +27,5 @@ COM_MOKODOLIJOOMSHOP_FIELD_TAX_ENABLED="Enable Tax"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_CONNECTION_OK="Dolibarr connection successful"
|
||||
COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED="Dolibarr connection failed. Check URL and API key."
|
||||
COM_MOKODOLIJOOMSHOP_DOLIBARR_VERSION="Dolibarr Version"
|
||||
COM_MOKODOLIJOOMSHOP_PERMISSIONS="API Permissions"
|
||||
COM_MOKODOLIJOOMSHOP_PERMISSION_READ="Products (read)"
|
||||
COM_MOKODOLIJOOMSHOP_PERMISSION_WRITE="Third-parties (read/write)"
|
||||
COM_MOKODOLIJOOMSHOP_TROUBLESHOOTING="Troubleshooting"
|
||||
COM_MOKODOLIJOOMSHOP_QUICK_ACTIONS="Quick Actions"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_SYNC_PRODUCTS="Sync Products"
|
||||
COM_MOKODOLIJOOMSHOP_SYNC_COMPLETE="Product sync complete: %d products updated"
|
||||
COM_MOKODOLIJOOMSHOP_NO_PRODUCTS="No products found."
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_REF="Reference"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL="Label"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_PRICE="Price"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_STOCK="Stock"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_STATUS="Status"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_TOSELL="For Sale"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_TOBUY="For Purchase"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_REF="Order Ref"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF="Invoice Ref"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_HT="Total (excl. tax)"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC="Total (incl. tax)"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_STATUS="Status"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_DATE="Date"
|
||||
COM_MOKODOLIJOOMSHOP_NO_ORDERS="No orders found."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME="Customer Name"
|
||||
COM_MOKODOLIJOOMSHOP_CUSTOMER_EMAIL="Email"
|
||||
COM_MOKODOLIJOOMSHOP_CUSTOMER_DOLIBARR_ID="Dolibarr ID"
|
||||
COM_MOKODOLIJOOMSHOP_CUSTOMER_SYNCED="Last Synced"
|
||||
COM_MOKODOLIJOOMSHOP_NO_CUSTOMERS="No customer mappings found."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_REVENUE="Revenue"
|
||||
COM_MOKODOLIJOOMSHOP_REVENUE_TODAY="Today"
|
||||
COM_MOKODOLIJOOMSHOP_REVENUE_WEEK="This Week"
|
||||
COM_MOKODOLIJOOMSHOP_REVENUE_MONTH="This Month"
|
||||
COM_MOKODOLIJOOMSHOP_RECENT_ORDERS="Recent Orders"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_TAX_DISPLAY="Tax Display Mode"
|
||||
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_TTC="Prices Include Tax (TTC)"
|
||||
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_HT="Prices Exclude Tax (HT)"
|
||||
COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_BOTH="Show Both (TTC and HT)"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_LOW_STOCK_THRESHOLD="Low Stock Threshold"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_ALLOW_BACKORDER="Allow Backorders"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_SUBJECT="Order Confirmation — %s"
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_ADMIN_ORDER_SUBJECT="New Order Received — %s"
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_GREETING="Hello %s,"
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_CONFIRMED="Thank you for your order! Here is your order summary:"
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_NEW_ORDER="A new order has been placed."
|
||||
COM_MOKODOLIJOOMSHOP_EMAIL_FOOTER="Thank you for shopping with %s."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_FIELDSET_PERFORMANCE="Performance"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_CACHE_ENABLED="Enable API Caching"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_CACHE_TTL="Cache Lifetime (seconds)"
|
||||
COM_MOKODOLIJOOMSHOP_FIELDSET_WEBHOOKS="Webhooks"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET="Webhook Secret"
|
||||
COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET_DESC="Shared secret for validating Dolibarr webhook requests."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_ACL_PRODUCTS_MANAGE="Manage Products"
|
||||
COM_MOKODOLIJOOMSHOP_ACL_ORDERS_VIEW="View Orders"
|
||||
COM_MOKODOLIJOOMSHOP_ACL_CUSTOMERS_MANAGE="Manage Customers"
|
||||
COM_MOKODOLIJOOMSHOP_ACL_SETTINGS_MANAGE="Manage Settings"
|
||||
|
||||
@@ -45,52 +45,6 @@ CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_orders` (
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Wishlist items (save for later)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_wishlist` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`session_id` varchar(255) NOT NULL DEFAULT '',
|
||||
`dolibarr_product_id` int(11) NOT NULL,
|
||||
`product_ref` varchar(128) NOT NULL DEFAULT '',
|
||||
`product_label` varchar(255) NOT NULL DEFAULT '',
|
||||
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`user_id`),
|
||||
KEY `idx_session` (`session_id`),
|
||||
UNIQUE KEY `idx_user_product` (`user_id`, `dolibarr_product_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Shipping addresses (user address book)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_addresses` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`label` varchar(100) NOT NULL DEFAULT '',
|
||||
`name` varchar(255) NOT NULL DEFAULT '',
|
||||
`address` text NOT NULL,
|
||||
`town` varchar(255) NOT NULL DEFAULT '',
|
||||
`zip` varchar(20) NOT NULL DEFAULT '',
|
||||
`country_code` varchar(5) NOT NULL DEFAULT '',
|
||||
`phone` varchar(50) NOT NULL DEFAULT '',
|
||||
`is_default` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Webhook event log
|
||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_webhook_log` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`event_type` varchar(100) NOT NULL,
|
||||
`payload` text NOT NULL,
|
||||
`status` varchar(20) NOT NULL DEFAULT 'received',
|
||||
`message` varchar(500) NOT NULL DEFAULT '',
|
||||
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_event_type` (`event_type`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Customer mapping (links Joomla users to Dolibarr thirdparties)
|
||||
CREATE TABLE IF NOT EXISTS `#__mokodolijoomshop_customers` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -6,6 +6,3 @@
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_cart`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_orders`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_customers`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_wishlist`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_addresses`;
|
||||
DROP TABLE IF EXISTS `#__mokodolijoomshop_webhook_log`;
|
||||
|
||||
@@ -13,7 +13,6 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Http\HttpFactory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* HTTP client for the Dolibarr REST API.
|
||||
@@ -37,28 +36,20 @@ class DolibarrClient
|
||||
*/
|
||||
private string $apiKey;
|
||||
|
||||
/**
|
||||
* @var bool Whether to verify SSL certificates.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private bool $verifySSL;
|
||||
|
||||
/**
|
||||
* Constructor. Reads connection settings from component params.
|
||||
*
|
||||
* @param string|null $baseUrl Override base URL.
|
||||
* @param string|null $apiKey Override API key.
|
||||
* @param bool|null $verifySSL Override SSL verification.
|
||||
* @param string|null $baseUrl Override base URL.
|
||||
* @param string|null $apiKey Override API key.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(?string $baseUrl = null, ?string $apiKey = null, ?bool $verifySSL = null)
|
||||
public function __construct(?string $baseUrl = null, ?string $apiKey = null)
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
|
||||
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
|
||||
$this->verifySSL = $verifySSL ?? (bool) $params->get('dolibarr_verify_ssl', true);
|
||||
$this->baseUrl = rtrim($baseUrl ?? $params->get('dolibarr_url', ''), '/');
|
||||
$this->apiKey = $apiKey ?? $params->get('dolibarr_api_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,20 +97,6 @@ class DolibarrClient
|
||||
return $this->request('PUT', $endpoint, [], $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DELETE request to the Dolibarr API.
|
||||
*
|
||||
* @param string $endpoint API endpoint.
|
||||
*
|
||||
* @return array|null Decoded JSON response, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function delete(string $endpoint): ?array
|
||||
{
|
||||
return $this->request('DELETE', $endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to the Dolibarr API.
|
||||
*
|
||||
@@ -134,82 +111,6 @@ class DolibarrClient
|
||||
return $result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed connection test returning status information.
|
||||
*
|
||||
* Checks connectivity, API version, and read/write permissions.
|
||||
*
|
||||
* @return array{ok: bool, version: string, permissions: array, error: string, hint: string}
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function testConnectionDetailed(): array
|
||||
{
|
||||
$result = [
|
||||
'ok' => false,
|
||||
'version' => '',
|
||||
'permissions' => ['read' => false, 'write' => false],
|
||||
'error' => '',
|
||||
'hint' => '',
|
||||
];
|
||||
|
||||
if (empty($this->baseUrl)) {
|
||||
$result['error'] = 'Dolibarr URL is not configured.';
|
||||
$result['hint'] = 'Set the Dolibarr URL in component options (e.g., https://erp.example.com).';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (empty($this->apiKey)) {
|
||||
$result['error'] = 'API key is not configured.';
|
||||
$result['hint'] = 'Generate an API key in Dolibarr: Setup > Security > API and set it in component options.';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Test basic connectivity
|
||||
$status = $this->get('/status');
|
||||
|
||||
if ($status === null) {
|
||||
$result['error'] = 'Cannot reach Dolibarr API.';
|
||||
$result['hint'] = 'Verify the URL is correct and the Dolibarr API module is enabled. '
|
||||
. 'Check: Home > Setup > Modules > Web Services API REST.';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['ok'] = true;
|
||||
$result['version'] = $status['success']['dolibarr_version'] ?? ($status['dolibarr_version'] ?? 'unknown');
|
||||
|
||||
// Test read access (list products, limit 1)
|
||||
$readTest = $this->get('/products', ['limit' => 1]);
|
||||
$result['permissions']['read'] = ($readTest !== null);
|
||||
|
||||
// Test write access by checking thirdparties access (non-destructive)
|
||||
$writeTest = $this->get('/thirdparties', ['limit' => 1]);
|
||||
$result['permissions']['write'] = ($writeTest !== null);
|
||||
|
||||
if (!$result['permissions']['read']) {
|
||||
$result['ok'] = false;
|
||||
$result['error'] = 'API key lacks read permission for products.';
|
||||
$result['hint'] = 'Ensure the API user has permissions: Products (read) and Third-parties (read/write).';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client is configured (has URL and key set).
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return !empty($this->baseUrl) && !empty($this->apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an HTTP request against the Dolibarr REST API.
|
||||
*
|
||||
@@ -246,13 +147,7 @@ class DolibarrClient
|
||||
|
||||
try
|
||||
{
|
||||
$options = new Registry();
|
||||
$options->set('transport.curl', [
|
||||
CURLOPT_SSL_VERIFYPEER => $this->verifySSL,
|
||||
CURLOPT_SSL_VERIFYHOST => $this->verifySSL ? 2 : 0,
|
||||
]);
|
||||
|
||||
$http = HttpFactory::getHttp($options);
|
||||
$http = HttpFactory::getHttp();
|
||||
$jsonBody = !empty($body) ? json_encode($body) : null;
|
||||
|
||||
switch (strtoupper($method))
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Dashboard model — provides metrics and sync data for the admin dashboard.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class DashboardModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product count from Dolibarr.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getProductCount(): int
|
||||
{
|
||||
$products = $this->client->get('/products', ['limit' => 0]);
|
||||
|
||||
return $products !== null ? \count($products) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local order count.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getOrderCount(): int
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'));
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer mapping count.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCustomerCount(): int
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_customers'));
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent orders from local table.
|
||||
*
|
||||
* @param int $limit Number of recent orders.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getRecentOrders(int $limit = 10): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->order($db->quoteName('created') . ' DESC');
|
||||
$db->setQuery($query, 0, $limit);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get revenue metrics.
|
||||
*
|
||||
* @return array{today: float, week: float, month: float}
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getRevenue(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate();
|
||||
$today = $now->format('Y-m-d');
|
||||
$week = Factory::getDate('-7 days')->format('Y-m-d');
|
||||
$month = Factory::getDate('-30 days')->format('Y-m-d');
|
||||
|
||||
$revenue = ['today' => 0.0, 'week' => 0.0, 'month' => 0.0];
|
||||
|
||||
// Today
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($today . ' 00:00:00'));
|
||||
$db->setQuery($query);
|
||||
$revenue['today'] = (float) $db->loadResult();
|
||||
|
||||
// Week
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($week . ' 00:00:00'));
|
||||
$db->setQuery($query);
|
||||
$revenue['week'] = (float) $db->loadResult();
|
||||
|
||||
// Month
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COALESCE(SUM(' . $db->quoteName('total_ttc') . '), 0)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($month . ' 00:00:00'));
|
||||
$db->setQuery($query);
|
||||
$revenue['month'] = (float) $db->loadResult();
|
||||
|
||||
return $revenue;
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Admin orders model — lists and manages orders from local mapping table.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class OrdersModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all orders with optional filters.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$status = $app->input->getString('filter_status', '');
|
||||
$dateFrom = $app->input->getString('filter_date_from', '');
|
||||
$dateTo = $app->input->getString('filter_date_to', '');
|
||||
$search = $app->input->getString('filter_search', '');
|
||||
|
||||
$query->select('o.*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders', 'o'))
|
||||
->order($db->quoteName('o.created') . ' DESC');
|
||||
|
||||
if (!empty($status))
|
||||
{
|
||||
$query->where($db->quoteName('o.status') . ' = ' . $db->quote($status));
|
||||
}
|
||||
|
||||
if (!empty($dateFrom))
|
||||
{
|
||||
$query->where($db->quoteName('o.created') . ' >= ' . $db->quote($dateFrom . ' 00:00:00'));
|
||||
}
|
||||
|
||||
if (!empty($dateTo))
|
||||
{
|
||||
$query->where($db->quoteName('o.created') . ' <= ' . $db->quote($dateTo . ' 23:59:59'));
|
||||
}
|
||||
|
||||
if (!empty($search))
|
||||
{
|
||||
$searchQuoted = $db->quote('%' . $search . '%');
|
||||
$query->where(
|
||||
'(' . $db->quoteName('o.order_ref') . ' LIKE ' . $searchQuoted
|
||||
. ' OR ' . $db->quoteName('o.invoice_ref') . ' LIKE ' . $searchQuoted . ')'
|
||||
);
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
$orders = $db->loadAssocList() ?: [];
|
||||
|
||||
// Enrich with user names
|
||||
foreach ($orders as &$order)
|
||||
{
|
||||
if ((int) $order['user_id'] > 0)
|
||||
{
|
||||
$userQuery = $db->getQuery(true);
|
||||
$userQuery->select($db->quoteName('name'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $order['user_id']);
|
||||
$db->setQuery($userQuery);
|
||||
$order['customer_name'] = $db->loadResult() ?: 'User #' . $order['user_id'];
|
||||
}
|
||||
else
|
||||
{
|
||||
$order['customer_name'] = 'Guest';
|
||||
}
|
||||
}
|
||||
|
||||
unset($order);
|
||||
|
||||
return $orders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync order status from Dolibarr for a specific order.
|
||||
*
|
||||
* @param int $localOrderId Local order table ID.
|
||||
*
|
||||
* @return string|null Updated status, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function syncOrderStatus(int $localOrderId): ?string
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('id') . ' = ' . $localOrderId);
|
||||
$db->setQuery($query);
|
||||
$local = $db->loadAssoc();
|
||||
|
||||
if (empty($local) || empty($local['dolibarr_order_id']))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$order = $this->client->get('/orders/' . (int) $local['dolibarr_order_id']);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$statusMap = [
|
||||
-1 => 'cancelled',
|
||||
0 => 'draft',
|
||||
1 => 'validated',
|
||||
2 => 'shipped',
|
||||
3 => 'delivered',
|
||||
];
|
||||
|
||||
$statusCode = (int) ($order['statut'] ?? $order['status'] ?? 0);
|
||||
$newStatus = $statusMap[$statusCode] ?? 'unknown';
|
||||
|
||||
// Update local status
|
||||
$update = $db->getQuery(true);
|
||||
$update->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote($newStatus))
|
||||
->where($db->quoteName('id') . ' = ' . $localOrderId);
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
|
||||
return $newStatus;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Cache\CacheControllerFactoryInterface;
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
/**
|
||||
* API response cache service using Joomla's cache framework.
|
||||
*
|
||||
* Caches Dolibarr API responses to reduce load and improve performance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CacheService
|
||||
{
|
||||
/**
|
||||
* @var string Cache group name.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private const GROUP = 'com_mokodolijoomshop';
|
||||
|
||||
/**
|
||||
* Get a cached value or execute the callback and cache the result.
|
||||
*
|
||||
* @param string $key Cache key.
|
||||
* @param callable $callback Function to call if cache miss.
|
||||
* @param int|null $ttl Time-to-live in seconds (null = use default).
|
||||
*
|
||||
* @return mixed
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function remember(string $key, callable $callback, ?int $ttl = null)
|
||||
{
|
||||
if (!self::isEnabled())
|
||||
{
|
||||
return $callback();
|
||||
}
|
||||
|
||||
$cache = self::getCache($ttl);
|
||||
$id = md5($key);
|
||||
|
||||
$result = $cache->get($id, self::GROUP);
|
||||
|
||||
if ($result !== false)
|
||||
{
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result = $callback();
|
||||
$cache->store($result, $id, self::GROUP);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a specific cache key.
|
||||
*
|
||||
* @param string $key Cache key to invalidate.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function forget(string $key): void
|
||||
{
|
||||
$cache = self::getCache();
|
||||
$cache->remove(md5($key), self::GROUP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all component cache (used during manual sync).
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function flush(): void
|
||||
{
|
||||
$cache = self::getCache();
|
||||
$cache->clean(self::GROUP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if caching is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (bool) $params->get('cache_enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default TTL in seconds.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getDefaultTtl(): int
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (int) $params->get('cache_ttl', 900); // 15 minutes default
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Joomla cache controller.
|
||||
*
|
||||
* @param int|null $ttl TTL override in seconds.
|
||||
*
|
||||
* @return \Joomla\CMS\Cache\CacheController
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function getCache(?int $ttl = null)
|
||||
{
|
||||
$options = [
|
||||
'defaultgroup' => self::GROUP,
|
||||
'caching' => true,
|
||||
'lifetime' => ($ttl ?? self::getDefaultTtl()) / 60,
|
||||
];
|
||||
|
||||
return Factory::getContainer()
|
||||
->get(CacheControllerFactoryInterface::class)
|
||||
->createCacheController('output', $options);
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\User\User;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Syncs Joomla users to Dolibarr thirdparties (customers).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CustomerSyncService
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DolibarrClient|null $client Optional client override.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(?DolibarrClient $client = null)
|
||||
{
|
||||
$this->client = $client ?? new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a Dolibarr thirdparty for the given Joomla user.
|
||||
*
|
||||
* Checks the local mapping table first, then searches Dolibarr by email,
|
||||
* and finally creates a new thirdparty if none exists.
|
||||
*
|
||||
* @param int $userId Joomla user ID.
|
||||
*
|
||||
* @return int|null Dolibarr thirdparty ID, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getOrCreateThirdparty(int $userId): ?int
|
||||
{
|
||||
// Check local mapping first
|
||||
$existingId = $this->getLocalMapping($userId);
|
||||
|
||||
if ($existingId !== null)
|
||||
{
|
||||
return $existingId;
|
||||
}
|
||||
|
||||
$user = Factory::getContainer()->get(\Joomla\CMS\User\UserFactoryInterface::class)->loadUserById($userId);
|
||||
|
||||
if ($user->guest || empty($user->email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search Dolibarr by email to avoid duplicates
|
||||
$existing = $this->findThirdpartyByEmail($user->email);
|
||||
|
||||
if ($existing !== null)
|
||||
{
|
||||
$this->saveMapping($userId, $existing);
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
// Create new thirdparty in Dolibarr
|
||||
$thirdpartyId = $this->createThirdparty($user);
|
||||
|
||||
if ($thirdpartyId !== null)
|
||||
{
|
||||
$this->saveMapping($userId, $thirdpartyId);
|
||||
}
|
||||
|
||||
return $thirdpartyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a guest customer in Dolibarr (no Joomla user mapping).
|
||||
*
|
||||
* @param string $name Customer name.
|
||||
* @param string $email Customer email.
|
||||
* @param string $address Billing address.
|
||||
* @param string $town City.
|
||||
* @param string $zip Postal code.
|
||||
* @param string $phone Phone number.
|
||||
*
|
||||
* @return int|null Dolibarr thirdparty ID.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function createGuestCustomer(
|
||||
string $name,
|
||||
string $email,
|
||||
string $address = '',
|
||||
string $town = '',
|
||||
string $zip = '',
|
||||
string $phone = ''
|
||||
): ?int {
|
||||
// Check if already exists by email
|
||||
$existing = $this->findThirdpartyByEmail($email);
|
||||
|
||||
if ($existing !== null)
|
||||
{
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'client' => 1,
|
||||
'code_client' => '-1',
|
||||
'address' => $address,
|
||||
'town' => $town,
|
||||
'zip' => $zip,
|
||||
'phone' => $phone,
|
||||
];
|
||||
|
||||
$result = $this->client->post('/thirdparties', $data);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
Log::add('CustomerSyncService: Failed to create guest thirdparty for ' . $email, Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the local mapping for a Joomla user.
|
||||
*
|
||||
* @param int $userId Joomla user ID.
|
||||
*
|
||||
* @return int|null Dolibarr thirdparty ID, or null if not mapped.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getLocalMapping(int $userId): ?int
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->select($db->quoteName('dolibarr_thirdparty_id'))
|
||||
->from($db->quoteName('#__mokodolijoomshop_customers'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
|
||||
$db->setQuery($query);
|
||||
$result = $db->loadResult();
|
||||
|
||||
return $result !== null ? (int) $result : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Dolibarr for a thirdparty matching the given email.
|
||||
*
|
||||
* @param string $email Email address to search.
|
||||
*
|
||||
* @return int|null Thirdparty ID or null.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function findThirdpartyByEmail(string $email): ?int
|
||||
{
|
||||
$results = $this->client->get('/thirdparties', [
|
||||
'sortfield' => 't.rowid',
|
||||
'sortorder' => 'ASC',
|
||||
'limit' => 1,
|
||||
'sqlfilters' => "(t.email:=:'" . addslashes($email) . "')",
|
||||
]);
|
||||
|
||||
if (!empty($results) && isset($results[0]['id']))
|
||||
{
|
||||
return (int) $results[0]['id'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Dolibarr thirdparty from a Joomla user.
|
||||
*
|
||||
* @param User $user Joomla user object.
|
||||
*
|
||||
* @return int|null Created thirdparty ID.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function createThirdparty(User $user): ?int
|
||||
{
|
||||
$data = [
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'client' => 1,
|
||||
'code_client' => '-1',
|
||||
];
|
||||
|
||||
$result = $this->client->post('/thirdparties', $data);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
Log::add(
|
||||
'CustomerSyncService: Failed to create thirdparty for user ' . $user->id,
|
||||
Log::ERROR,
|
||||
'com_mokodolijoomshop'
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a user ↔ thirdparty mapping in the local database.
|
||||
*
|
||||
* @param int $userId Joomla user ID.
|
||||
* @param int $thirdpartyId Dolibarr thirdparty ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function saveMapping(int $userId, int $thirdpartyId): bool
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$table = new \Moko\Component\MokoDoliJoomShop\Administrator\Table\CustomerTable($db);
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'dolibarr_thirdparty_id' => $thirdpartyId,
|
||||
'synced_at' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$table->bind($data);
|
||||
|
||||
if (!$table->check())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return $table->store();
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Mail\MailerFactoryInterface;
|
||||
|
||||
/**
|
||||
* Email notification service for order events.
|
||||
*
|
||||
* Sends customer confirmation and admin notification emails
|
||||
* using Joomla's mail transport system.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class EmailService
|
||||
{
|
||||
/**
|
||||
* Send order confirmation email to the customer.
|
||||
*
|
||||
* @param string $customerEmail Customer email address.
|
||||
* @param string $customerName Customer name.
|
||||
* @param array $orderData Order result data (order_ref, invoice_ref).
|
||||
* @param array $cartItems Cart items at time of order.
|
||||
* @param array $totals Cart totals (subtotal, tax, total).
|
||||
*
|
||||
* @return bool True if sent successfully.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function sendCustomerConfirmation(
|
||||
string $customerEmail,
|
||||
string $customerName,
|
||||
array $orderData,
|
||||
array $cartItems,
|
||||
array $totals
|
||||
): bool {
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$currency = $params->get('currency', 'USD');
|
||||
$siteName = Factory::getApplication()->get('sitename');
|
||||
|
||||
$subject = Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_SUBJECT', $orderData['order_ref'] ?? '');
|
||||
|
||||
$body = $this->buildCustomerEmailBody(
|
||||
$customerName,
|
||||
$orderData,
|
||||
$cartItems,
|
||||
$totals,
|
||||
$currency,
|
||||
$siteName
|
||||
);
|
||||
|
||||
return $this->sendMail($customerEmail, $subject, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send order notification email to the admin.
|
||||
*
|
||||
* @param array $orderData Order result data.
|
||||
* @param array $cartItems Cart items.
|
||||
* @param array $totals Cart totals.
|
||||
* @param string $customerName Customer name.
|
||||
* @param string $customerEmail Customer email.
|
||||
*
|
||||
* @return bool True if sent successfully.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function sendAdminNotification(
|
||||
array $orderData,
|
||||
array $cartItems,
|
||||
array $totals,
|
||||
string $customerName,
|
||||
string $customerEmail
|
||||
): bool {
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$currency = $params->get('currency', 'USD');
|
||||
$adminMail = Factory::getApplication()->get('mailfrom');
|
||||
|
||||
$subject = Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_ADMIN_ORDER_SUBJECT', $orderData['order_ref'] ?? '');
|
||||
|
||||
$body = $this->buildAdminEmailBody(
|
||||
$orderData,
|
||||
$cartItems,
|
||||
$totals,
|
||||
$currency,
|
||||
$customerName,
|
||||
$customerEmail
|
||||
);
|
||||
|
||||
return $this->sendMail($adminMail, $subject, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer confirmation email HTML body.
|
||||
*
|
||||
* @param string $name Customer name.
|
||||
* @param array $order Order data.
|
||||
* @param array $items Cart items.
|
||||
* @param array $totals Totals.
|
||||
* @param string $currency Currency code.
|
||||
* @param string $siteName Site name.
|
||||
*
|
||||
* @return string HTML email body.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function buildCustomerEmailBody(
|
||||
string $name,
|
||||
array $order,
|
||||
array $items,
|
||||
array $totals,
|
||||
string $currency,
|
||||
string $siteName
|
||||
): string {
|
||||
$orderRef = htmlspecialchars($order['order_ref'] ?? '');
|
||||
$invoiceRef = htmlspecialchars($order['invoice_ref'] ?? '');
|
||||
|
||||
$html = '<html><body style="font-family: Arial, sans-serif; line-height: 1.6;">';
|
||||
$html .= '<h2>' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_GREETING', htmlspecialchars($name)) . '</h2>';
|
||||
$html .= '<p>' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_ORDER_CONFIRMED') . '</p>';
|
||||
$html .= '<table style="border-collapse:collapse; width:100%; margin:20px 0;">';
|
||||
$html .= '<tr><td><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':</strong></td><td>' . $orderRef . '</td></tr>';
|
||||
|
||||
if ($invoiceRef)
|
||||
{
|
||||
$html .= '<tr><td><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF') . ':</strong></td><td>' . $invoiceRef . '</td></tr>';
|
||||
}
|
||||
|
||||
$html .= '</table>';
|
||||
|
||||
// Items table
|
||||
$html .= '<table style="border-collapse:collapse; width:100%; margin:20px 0; border:1px solid #ddd;">';
|
||||
$html .= '<thead><tr style="background:#f5f5f5;">';
|
||||
$html .= '<th style="padding:8px; text-align:left; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL') . '</th>';
|
||||
$html .= '<th style="padding:8px; text-align:center; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . '</th>';
|
||||
$html .= '<th style="padding:8px; text-align:right; border:1px solid #ddd;">' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '</th>';
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||
$html .= '<tr>';
|
||||
$html .= '<td style="padding:8px; border:1px solid #ddd;">' . htmlspecialchars($item['product_label']) . '</td>';
|
||||
$html .= '<td style="padding:8px; text-align:center; border:1px solid #ddd;">' . (int) $item['quantity'] . '</td>';
|
||||
$html .= '<td style="padding:8px; text-align:right; border:1px solid #ddd;">' . number_format($lineTotal, 2) . ' ' . $currency . '</td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table>';
|
||||
|
||||
// Totals
|
||||
$html .= '<table style="width:300px; margin-left:auto;">';
|
||||
$html .= '<tr><td>' . Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL') . '</td><td style="text-align:right;">' . number_format($totals['subtotal'], 2) . ' ' . $currency . '</td></tr>';
|
||||
|
||||
if ($totals['tax'] > 0)
|
||||
{
|
||||
$html .= '<tr><td>' . Text::_('COM_MOKODOLIJOOMSHOP_TAX') . '</td><td style="text-align:right;">' . number_format($totals['tax'], 2) . ' ' . $currency . '</td></tr>';
|
||||
}
|
||||
|
||||
$html .= '<tr style="font-weight:bold; font-size:1.2em;"><td>' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . '</td><td style="text-align:right;">' . number_format($totals['total'], 2) . ' ' . $currency . '</td></tr>';
|
||||
$html .= '</table>';
|
||||
|
||||
$html .= '<p style="margin-top:30px; color:#666;">' . Text::sprintf('COM_MOKODOLIJOOMSHOP_EMAIL_FOOTER', htmlspecialchars($siteName)) . '</p>';
|
||||
$html .= '</body></html>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the admin notification email body.
|
||||
*
|
||||
* @param array $order Order data.
|
||||
* @param array $items Cart items.
|
||||
* @param array $totals Totals.
|
||||
* @param string $currency Currency.
|
||||
* @param string $customerName Customer name.
|
||||
* @param string $customerEmail Customer email.
|
||||
*
|
||||
* @return string HTML body.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function buildAdminEmailBody(
|
||||
array $order,
|
||||
array $items,
|
||||
array $totals,
|
||||
string $currency,
|
||||
string $customerName,
|
||||
string $customerEmail
|
||||
): string {
|
||||
$html = '<html><body style="font-family: Arial, sans-serif;">';
|
||||
$html .= '<h2>' . Text::_('COM_MOKODOLIJOOMSHOP_EMAIL_NEW_ORDER') . '</h2>';
|
||||
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF') . ':</strong> ' . htmlspecialchars($order['order_ref'] ?? '') . '</p>';
|
||||
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME') . ':</strong> ' . htmlspecialchars($customerName) . ' (' . htmlspecialchars($customerEmail) . ')</p>';
|
||||
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_TOTAL') . ':</strong> ' . number_format($totals['total'], 2) . ' ' . $currency . '</p>';
|
||||
$html .= '<p><strong>' . Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY') . ':</strong> ' . \count($items) . ' item(s)</p>';
|
||||
$html .= '</body></html>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an HTML email using Joomla's mail system.
|
||||
*
|
||||
* @param string $to Recipient email.
|
||||
* @param string $subject Email subject.
|
||||
* @param string $body HTML body.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function sendMail(string $to, string $subject, string $body): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$mailer = Factory::getContainer()->get(MailerFactoryInterface::class)->createMailer();
|
||||
$mailer->addRecipient($to);
|
||||
$mailer->setSubject($subject);
|
||||
$mailer->setBody($body);
|
||||
$mailer->isHtml(true);
|
||||
|
||||
return $mailer->Send();
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
Log::add('EmailService: ' . $e->getMessage(), Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Filesystem\File;
|
||||
use Joomla\CMS\Filesystem\Folder;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Product image service — fetches, caches, and serves product images.
|
||||
*
|
||||
* Images are stored in: /media/com_mokodolijoomshop/images/products/{product_id}/
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class ImageService
|
||||
{
|
||||
/**
|
||||
* @var string Base path for cached images.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private string $basePath;
|
||||
|
||||
/**
|
||||
* @var string Base URL for cached images.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private string $baseUrl;
|
||||
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* @var string Placeholder image path.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private const PLACEHOLDER = 'media/com_mokodolijoomshop/images/placeholder.png';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DolibarrClient|null $client Optional client override.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(?DolibarrClient $client = null)
|
||||
{
|
||||
$this->client = $client ?? new DolibarrClient();
|
||||
$this->basePath = JPATH_ROOT . '/media/com_mokodolijoomshop/images/products';
|
||||
$this->baseUrl = Uri::root() . 'media/com_mokodolijoomshop/images/products';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image URLs for a product, fetching from Dolibarr if not cached.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return array Array of image URLs (local cached paths).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getProductImages(int $productId): array
|
||||
{
|
||||
$productDir = $this->basePath . '/' . $productId;
|
||||
|
||||
// Check cache first
|
||||
if (is_dir($productDir))
|
||||
{
|
||||
$files = Folder::files($productDir, '\.(jpe?g|png|gif|webp)$', false, true);
|
||||
|
||||
if (!empty($files))
|
||||
{
|
||||
return array_map(function ($file) use ($productId) {
|
||||
return $this->baseUrl . '/' . $productId . '/' . basename($file);
|
||||
}, $files);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from Dolibarr
|
||||
return $this->fetchAndCache($productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single thumbnail URL for list views.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return string Image URL or placeholder.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getThumbnail(int $productId): string
|
||||
{
|
||||
$images = $this->getProductImages($productId);
|
||||
|
||||
if (empty($images))
|
||||
{
|
||||
return Uri::root() . self::PLACEHOLDER;
|
||||
}
|
||||
|
||||
// Return first image as thumbnail
|
||||
return $images[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the placeholder image URL.
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getPlaceholder(): string
|
||||
{
|
||||
return Uri::root() . self::PLACEHOLDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the image cache for a product (used during sync).
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function invalidateCache(int $productId): bool
|
||||
{
|
||||
$productDir = $this->basePath . '/' . $productId;
|
||||
|
||||
if (is_dir($productDir))
|
||||
{
|
||||
return Folder::delete($productDir);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached images.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function invalidateAll(): bool
|
||||
{
|
||||
if (is_dir($this->basePath))
|
||||
{
|
||||
return Folder::delete($this->basePath) && Folder::create($this->basePath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch product images from Dolibarr and cache them locally.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return array Array of cached image URLs.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function fetchAndCache(int $productId): array
|
||||
{
|
||||
$docs = $this->client->get('/documents', [
|
||||
'modulepart' => 'product',
|
||||
'id' => $productId,
|
||||
]);
|
||||
|
||||
if (empty($docs) || !\is_array($docs))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$productDir = $this->basePath . '/' . $productId;
|
||||
|
||||
if (!is_dir($productDir))
|
||||
{
|
||||
Folder::create($productDir);
|
||||
}
|
||||
|
||||
$urls = [];
|
||||
|
||||
foreach ($docs as $doc)
|
||||
{
|
||||
$filename = $doc['name'] ?? basename($doc['relativename'] ?? '');
|
||||
|
||||
if (!preg_match('/\.(jpe?g|png|gif|webp)$/i', $filename))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download the file content
|
||||
$content = null;
|
||||
|
||||
if (!empty($doc['content']))
|
||||
{
|
||||
// Base64 encoded content
|
||||
$content = base64_decode($doc['content']);
|
||||
}
|
||||
elseif (!empty($doc['fullname']))
|
||||
{
|
||||
// Fetch via documents/download endpoint
|
||||
$download = $this->client->get('/documents/download', [
|
||||
'modulepart' => 'product',
|
||||
'original_file' => $doc['relativename'] ?? $filename,
|
||||
]);
|
||||
|
||||
if (!empty($download['content']))
|
||||
{
|
||||
$content = base64_decode($download['content']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($content !== null)
|
||||
{
|
||||
$localPath = $productDir . '/' . $filename;
|
||||
File::write($localPath, $content);
|
||||
$urls[] = $this->baseUrl . '/' . $productId . '/' . $filename;
|
||||
}
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Table\OrderTable;
|
||||
|
||||
/**
|
||||
* Creates orders and invoices in Dolibarr from cart data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class OrderService
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DolibarrClient|null $client Optional client override.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(?DolibarrClient $client = null)
|
||||
{
|
||||
$this->client = $client ?? new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an order in Dolibarr from cart items.
|
||||
*
|
||||
* @param int $thirdpartyId Dolibarr thirdparty (customer) ID.
|
||||
* @param array $cartItems Cart items array from CartModel::getItems().
|
||||
* @param array $metadata Additional order metadata (note_public, note_private, etc.).
|
||||
*
|
||||
* @return array|null Array with order data, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function createOrder(int $thirdpartyId, array $cartItems, array $metadata = []): ?array
|
||||
{
|
||||
if (empty($cartItems))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build line items
|
||||
$lines = [];
|
||||
|
||||
foreach ($cartItems as $item)
|
||||
{
|
||||
$lines[] = [
|
||||
'fk_product' => (int) $item['dolibarr_product_id'],
|
||||
'qty' => (int) $item['quantity'],
|
||||
'subprice' => (float) $item['unit_price'],
|
||||
'tva_tx' => (float) $item['tax_rate'],
|
||||
'product_type' => 0,
|
||||
'desc' => $item['product_label'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$orderData = [
|
||||
'socid' => $thirdpartyId,
|
||||
'date' => date('Y-m-d'),
|
||||
'lines' => $lines,
|
||||
'note_public' => $metadata['note_public'] ?? '',
|
||||
'note_private' => $metadata['note_private'] ?? '',
|
||||
];
|
||||
|
||||
$result = $this->client->post('/orders', $orderData);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
Log::add('OrderService: Failed to create order for thirdparty ' . $thirdpartyId, Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderId = (int) $result;
|
||||
|
||||
// Fetch created order details
|
||||
$order = $this->client->get('/orders/' . $orderId);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
return ['id' => $orderId, 'ref' => ''];
|
||||
}
|
||||
|
||||
// Validate (set to status 1 = validated)
|
||||
$this->client->post('/orders/' . $orderId . '/validate', []);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invoice from a Dolibarr order.
|
||||
*
|
||||
* @param int $orderId Dolibarr order ID.
|
||||
*
|
||||
* @return array|null Invoice data, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function createInvoiceFromOrder(int $orderId): ?array
|
||||
{
|
||||
$invoiceData = [
|
||||
'socid' => 0,
|
||||
];
|
||||
|
||||
// Use createfromorder endpoint
|
||||
$result = $this->client->post('/invoices/createfromorder/' . $orderId, $invoiceData);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
// Fallback: create invoice manually from order data
|
||||
$order = $this->client->get('/orders/' . $orderId);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
Log::add('OrderService: Failed to create invoice from order ' . $orderId, Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
|
||||
foreach ($order['lines'] ?? [] as $line)
|
||||
{
|
||||
$lines[] = [
|
||||
'fk_product' => (int) ($line['fk_product'] ?? 0),
|
||||
'qty' => (float) ($line['qty'] ?? 1),
|
||||
'subprice' => (float) ($line['subprice'] ?? 0),
|
||||
'tva_tx' => (float) ($line['tva_tx'] ?? 0),
|
||||
'product_type' => (int) ($line['product_type'] ?? 0),
|
||||
'desc' => $line['desc'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$invoicePayload = [
|
||||
'socid' => (int) ($order['socid'] ?? 0),
|
||||
'date' => date('Y-m-d'),
|
||||
'lines' => $lines,
|
||||
'linked_objects' => ['commande' => $orderId],
|
||||
];
|
||||
|
||||
$result = $this->client->post('/invoices', $invoicePayload);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$invoiceId = (int) $result;
|
||||
$this->client->post('/invoices/' . $invoiceId . '/validate', []);
|
||||
|
||||
return $this->client->get('/invoices/' . $invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save order mapping to local database.
|
||||
*
|
||||
* @param int $userId Joomla user ID (0 for guest).
|
||||
* @param int $orderId Dolibarr order ID.
|
||||
* @param int $invoiceId Dolibarr invoice ID.
|
||||
* @param int $thirdpartyId Dolibarr thirdparty ID.
|
||||
* @param string $orderRef Order reference string.
|
||||
* @param string $invoiceRef Invoice reference string.
|
||||
* @param float $totalHT Total excl. tax.
|
||||
* @param float $totalTTC Total incl. tax.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function saveOrderMapping(
|
||||
int $userId,
|
||||
int $orderId,
|
||||
int $invoiceId,
|
||||
int $thirdpartyId,
|
||||
string $orderRef,
|
||||
string $invoiceRef,
|
||||
float $totalHT,
|
||||
float $totalTTC
|
||||
): bool {
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$table = new OrderTable($db);
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'dolibarr_order_id' => $orderId,
|
||||
'dolibarr_invoice_id' => $invoiceId,
|
||||
'dolibarr_thirdparty_id' => $thirdpartyId,
|
||||
'order_ref' => $orderRef,
|
||||
'invoice_ref' => $invoiceRef,
|
||||
'total_ht' => $totalHT,
|
||||
'total_ttc' => $totalTTC,
|
||||
'status' => 'confirmed',
|
||||
];
|
||||
|
||||
$table->bind($data);
|
||||
|
||||
if (!$table->check())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return $table->store();
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
/**
|
||||
* Webhook service — receives and processes Dolibarr webhook events.
|
||||
*
|
||||
* Endpoint: /api/mokodolijoomshop/webhook
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class WebhookService
|
||||
{
|
||||
/**
|
||||
* Validate the webhook secret.
|
||||
*
|
||||
* @param string $providedSecret Secret from request header.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function validateSecret(string $providedSecret): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$expectedSecret = $params->get('webhook_secret', '');
|
||||
|
||||
if (empty($expectedSecret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($expectedSecret, $providedSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming webhook event.
|
||||
*
|
||||
* @param string $eventType Event type (e.g., 'PRODUCT_CREATE', 'ORDER_UPDATE').
|
||||
* @param array $payload Event payload data.
|
||||
*
|
||||
* @return bool True if processed successfully.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function processEvent(string $eventType, array $payload): bool
|
||||
{
|
||||
$this->logEvent($eventType, $payload, 'processing');
|
||||
|
||||
try
|
||||
{
|
||||
switch ($eventType)
|
||||
{
|
||||
case 'PRODUCT_CREATE':
|
||||
case 'PRODUCT_MODIFY':
|
||||
$this->handleProductChange($payload);
|
||||
break;
|
||||
|
||||
case 'PRODUCT_DELETE':
|
||||
$this->handleProductDelete($payload);
|
||||
break;
|
||||
|
||||
case 'ORDER_VALIDATE':
|
||||
case 'ORDER_MODIFY':
|
||||
case 'ORDER_CLOSE':
|
||||
case 'ORDER_CANCEL':
|
||||
$this->handleOrderStatusChange($payload);
|
||||
break;
|
||||
|
||||
case 'PAYMENT_CUSTOMER_CREATE':
|
||||
$this->handlePaymentReceived($payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->logEvent($eventType, $payload, 'ignored', 'Unknown event type');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->logEvent($eventType, $payload, 'success');
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
$this->logEvent($eventType, $payload, 'error', $e->getMessage());
|
||||
Log::add('WebhookService: ' . $e->getMessage(), Log::ERROR, 'com_mokodolijoomshop');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle product create/modify — invalidate image cache.
|
||||
*
|
||||
* @param array $payload Event payload.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function handleProductChange(array $payload): void
|
||||
{
|
||||
$productId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||
|
||||
if ($productId > 0)
|
||||
{
|
||||
// Invalidate cached images for this product
|
||||
$imageService = new ImageService();
|
||||
$imageService->invalidateCache($productId);
|
||||
|
||||
// Clear API cache
|
||||
CacheService::forget('products_list');
|
||||
CacheService::forget('product_' . $productId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle product deletion.
|
||||
*
|
||||
* @param array $payload Event payload.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function handleProductDelete(array $payload): void
|
||||
{
|
||||
$productId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||
|
||||
if ($productId > 0)
|
||||
{
|
||||
$imageService = new ImageService();
|
||||
$imageService->invalidateCache($productId);
|
||||
CacheService::flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order status changes — update local mapping.
|
||||
*
|
||||
* @param array $payload Event payload.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function handleOrderStatusChange(array $payload): void
|
||||
{
|
||||
$orderId = (int) ($payload['object_id'] ?? $payload['id'] ?? 0);
|
||||
|
||||
if ($orderId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$statusMap = [
|
||||
-1 => 'cancelled',
|
||||
0 => 'draft',
|
||||
1 => 'validated',
|
||||
2 => 'shipped',
|
||||
3 => 'delivered',
|
||||
];
|
||||
|
||||
$statusCode = (int) ($payload['object_status'] ?? $payload['status'] ?? 0);
|
||||
$newStatus = $statusMap[$statusCode] ?? 'unknown';
|
||||
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote($newStatus))
|
||||
->where($db->quoteName('dolibarr_order_id') . ' = ' . $orderId);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payment received — update order status to paid.
|
||||
*
|
||||
* @param array $payload Event payload.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function handlePaymentReceived(array $payload): void
|
||||
{
|
||||
$invoiceId = (int) ($payload['object_id'] ?? 0);
|
||||
|
||||
if ($invoiceId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('paid'))
|
||||
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a webhook event to the database.
|
||||
*
|
||||
* @param string $eventType Event type.
|
||||
* @param array $payload Payload data.
|
||||
* @param string $status Processing status.
|
||||
* @param string $message Optional message.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function logEvent(string $eventType, array $payload, string $status, string $message = ''): void
|
||||
{
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->insert($db->quoteName('#__mokodolijoomshop_webhook_log'))
|
||||
->columns(['event_type', 'payload', 'status', 'message'])
|
||||
->values(implode(',', [
|
||||
$db->quote($eventType),
|
||||
$db->quote(json_encode($payload)),
|
||||
$db->quote($status),
|
||||
$db->quote(mb_substr($message, 0, 500)),
|
||||
]));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
/**
|
||||
* Cart item table class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CartTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database connector.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokodolijoomshop_cart', 'id', $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation before store.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->session_id) && empty($this->user_id))
|
||||
{
|
||||
$this->setError('Cart item must have a session_id or user_id.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->dolibarr_product_id))
|
||||
{
|
||||
$this->setError('Cart item must have a product ID.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->quantity < 1)
|
||||
{
|
||||
$this->quantity = 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
/**
|
||||
* Customer mapping table class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CustomerTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database connector.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokodolijoomshop_customers', 'id', $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation before store.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->user_id))
|
||||
{
|
||||
$this->setError('Customer mapping must have a Joomla user_id.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->dolibarr_thirdparty_id))
|
||||
{
|
||||
$this->setError('Customer mapping must have a Dolibarr thirdparty_id.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
/**
|
||||
* Order mapping table class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class OrderTable extends Table
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DatabaseDriver $db Database connector.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokodolijoomshop_orders', 'id', $db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation before store.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->dolibarr_order_id))
|
||||
{
|
||||
$this->setError('Order mapping must have a Dolibarr order ID.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,9 @@ namespace Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Model\DashboardModel;
|
||||
|
||||
/**
|
||||
* Dashboard view for the admin.
|
||||
@@ -29,48 +27,6 @@ class HtmlView extends BaseHtmlView
|
||||
*/
|
||||
protected bool $connectionOk = false;
|
||||
|
||||
/**
|
||||
* @var array Detailed connection status from DolibarrClient.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $connectionStatus = [];
|
||||
|
||||
/**
|
||||
* @var int Product count.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $productCount = 0;
|
||||
|
||||
/**
|
||||
* @var int Order count.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $orderCount = 0;
|
||||
|
||||
/**
|
||||
* @var int Customer count.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $customerCount = 0;
|
||||
|
||||
/**
|
||||
* @var array Recent orders.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $recentOrders = [];
|
||||
|
||||
/**
|
||||
* @var array Revenue metrics.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $revenue = [];
|
||||
|
||||
/**
|
||||
* @var string Currency.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* Display the dashboard.
|
||||
*
|
||||
@@ -83,21 +39,7 @@ class HtmlView extends BaseHtmlView
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$client = new DolibarrClient();
|
||||
$this->connectionStatus = $client->testConnectionDetailed();
|
||||
$this->connectionOk = $this->connectionStatus['ok'];
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
|
||||
if ($this->connectionOk)
|
||||
{
|
||||
$dashModel = new DashboardModel();
|
||||
$this->productCount = $dashModel->getProductCount();
|
||||
$this->orderCount = $dashModel->getOrderCount();
|
||||
$this->customerCount = $dashModel->getCustomerCount();
|
||||
$this->recentOrders = $dashModel->getRecentOrders(5);
|
||||
$this->revenue = $dashModel->getRevenue();
|
||||
}
|
||||
$this->connectionOk = $client->testConnection();
|
||||
|
||||
ToolbarHelper::title('DoliJoom Shop: Dashboard');
|
||||
ToolbarHelper::preferences('com_mokodolijoomshop');
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Administrator\View\Orders;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
/**
|
||||
* Admin orders list view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Order items.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $items = [];
|
||||
|
||||
/**
|
||||
* @var string Currency.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* Display the orders list.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$this->items = $model->getItems();
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
|
||||
ToolbarHelper::title('DoliJoom Shop: Orders');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -9,177 +9,49 @@
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Dashboard\HtmlView $this */
|
||||
|
||||
$status = $this->connectionStatus;
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-dashboard">
|
||||
<!-- Connection Status -->
|
||||
<div class="row mb-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_FIELDSET_DOLIBARR'); ?></h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if ($this->connectionOk) : ?>
|
||||
<div class="alert alert-success mb-2">
|
||||
<div class="alert alert-success">
|
||||
<span class="icon-check" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_OK'); ?>
|
||||
</div>
|
||||
<?php if (!empty($status['version'])) : ?>
|
||||
<p class="mb-1">
|
||||
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_DOLIBARR_VERSION'); ?>:</strong>
|
||||
<?php echo htmlspecialchars($status['version']); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<p class="mb-0">
|
||||
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSIONS'); ?>:</strong>
|
||||
<span class="icon-<?php echo $status['permissions']['read'] ? 'check text-success' : 'times text-danger'; ?>" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSION_READ'); ?>
|
||||
|
||||
<span class="icon-<?php echo $status['permissions']['write'] ? 'check text-success' : 'times text-danger'; ?>" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PERMISSION_WRITE'); ?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<div class="alert alert-danger">
|
||||
<span class="icon-warning" aria-hidden="true"></span>
|
||||
<?php echo htmlspecialchars($status['error'] ?: Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED')); ?>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONNECTION_FAILED'); ?>
|
||||
</div>
|
||||
<?php if (!empty($status['hint'])) : ?>
|
||||
<div class="alert alert-info mb-0">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<strong><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TROUBLESHOOTING'); ?>:</strong>
|
||||
<?php echo htmlspecialchars($status['hint']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUICK_ACTIONS'); ?></h3>
|
||||
<h3 class="card-title">Quick Actions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary mb-2 d-block">
|
||||
<span class="icon-cube" aria-hidden="true"></span>
|
||||
<a href="index.php?option=com_mokodolijoomshop&view=products" class="btn btn-primary mb-2 d-block">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" class="btn btn-outline-primary mb-2 d-block">
|
||||
<span class="icon-cart" aria-hidden="true"></span>
|
||||
<a href="index.php?option=com_mokodolijoomshop&view=orders" class="btn btn-outline-primary mb-2 d-block">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=customers'); ?>" class="btn btn-outline-primary mb-2 d-block">
|
||||
<span class="icon-users" aria-hidden="true"></span>
|
||||
<a href="index.php?option=com_mokodolijoomshop&view=customers" class="btn btn-outline-primary mb-2 d-block">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMERS'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($this->connectionOk) : ?>
|
||||
<!-- Metrics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?></h5>
|
||||
<p class="display-6 fw-bold mb-0"><?php echo $this->productCount; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS'); ?></h5>
|
||||
<p class="display-6 fw-bold mb-0"><?php echo $this->orderCount; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMERS'); ?></h5>
|
||||
<p class="display-6 fw-bold mb-0"><?php echo $this->customerCount; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h5 class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_MONTH'); ?></h5>
|
||||
<p class="display-6 fw-bold mb-0"><?php echo number_format($this->revenue['month'] ?? 0, 2); ?> <?php echo $currency; ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Breakdown & Recent Orders -->
|
||||
<div class="row">
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_TODAY'); ?></td>
|
||||
<td class="text-end fw-bold"><?php echo number_format($this->revenue['today'] ?? 0, 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_WEEK'); ?></td>
|
||||
<td class="text-end fw-bold"><?php echo number_format($this->revenue['week'] ?? 0, 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_REVENUE_MONTH'); ?></td>
|
||||
<td class="text-end fw-bold"><?php echo number_format($this->revenue['month'] ?? 0, 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_RECENT_ORDERS'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php if (empty($this->recentOrders)) : ?>
|
||||
<p class="p-3 text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->recentOrders as $order) : ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($order['order_ref']); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) $order['total_ttc'], 2); ?> <?php echo $currency; ?></td>
|
||||
<td><span class="badge bg-secondary"><?php echo htmlspecialchars($order['status']); ?></span></td>
|
||||
<td><?php echo htmlspecialchars($order['created']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Administrator\View\Orders\HtmlView $this */
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>" method="get" id="adminForm" name="adminForm">
|
||||
<input type="hidden" name="option" value="com_mokodolijoomshop" />
|
||||
<input type="hidden" name="view" value="orders" />
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="filter_search" class="form-control" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_SEARCH'); ?>"
|
||||
value="<?php echo htmlspecialchars(\Joomla\CMS\Factory::getApplication()->input->getString('filter_search', '')); ?>" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="filter_status" class="form-select">
|
||||
<option value=""><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="validated">Validated</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="filter_date_from" class="form-control" placeholder="From" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="filter_date_to" class="form-control" placeholder="To" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-search" aria-hidden="true"></span>
|
||||
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
|
||||
<?php else : ?>
|
||||
<table class="table table-striped" id="orderList">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CUSTOMER_NAME'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_HT'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_TOTAL_TTC'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $order) : ?>
|
||||
<?php
|
||||
$statusClass = match ($order['status'] ?? '') {
|
||||
'confirmed', 'validated' => 'bg-success',
|
||||
'shipped' => 'bg-info',
|
||||
'delivered' => 'bg-primary',
|
||||
'cancelled' => 'bg-danger',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($order['created']); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['customer_name'] ?? ''); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['order_ref']); ?></td>
|
||||
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) $order['total_ht'], 2); ?> <?php echo $currency; ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) $order['total_ttc'], 2); ?> <?php echo $currency; ?></td>
|
||||
<td><span class="badge <?php echo $statusClass; ?>"><?php echo htmlspecialchars($order['status']); ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
@@ -37,14 +37,8 @@
|
||||
</sql>
|
||||
</uninstall>
|
||||
|
||||
<media destination="com_mokodolijoomshop" folder="../media/com_mokodolijoomshop">
|
||||
<folder>css</folder>
|
||||
<folder>images</folder>
|
||||
</media>
|
||||
|
||||
<files folder="site">
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
@@ -133,77 +127,10 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="tax_display"
|
||||
type="list"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_TAX_DISPLAY"
|
||||
default="ttc"
|
||||
>
|
||||
<option value="ttc">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_TTC</option>
|
||||
<option value="ht">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_HT</option>
|
||||
<option value="both">COM_MOKODOLIJOOMSHOP_TAX_DISPLAY_BOTH</option>
|
||||
</field>
|
||||
<field
|
||||
name="low_stock_threshold"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_LOW_STOCK_THRESHOLD"
|
||||
default="5"
|
||||
min="0"
|
||||
max="999"
|
||||
/>
|
||||
<field
|
||||
name="allow_backorder"
|
||||
type="radio"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_ALLOW_BACKORDER"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="performance" label="COM_MOKODOLIJOOMSHOP_FIELDSET_PERFORMANCE">
|
||||
<field
|
||||
name="cache_enabled"
|
||||
type="radio"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_CACHE_ENABLED"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="cache_ttl"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_CACHE_TTL"
|
||||
default="900"
|
||||
min="60"
|
||||
max="86400"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="webhooks" label="COM_MOKODOLIJOOMSHOP_FIELDSET_WEBHOOKS">
|
||||
<field
|
||||
name="webhook_secret"
|
||||
type="password"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET"
|
||||
description="COM_MOKODOLIJOOMSHOP_FIELD_WEBHOOK_SECRET_DESC"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
|
||||
<access section="component">
|
||||
<action name="core.admin" title="JACTION_ADMIN" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" />
|
||||
<action name="mokodolijoomshop.products.manage" title="COM_MOKODOLIJOOMSHOP_ACL_PRODUCTS_MANAGE" />
|
||||
<action name="mokodolijoomshop.orders.view" title="COM_MOKODOLIJOOMSHOP_ACL_ORDERS_VIEW" />
|
||||
<action name="mokodolijoomshop.customers.manage" title="COM_MOKODOLIJOOMSHOP_ACL_CUSTOMERS_MANAGE" />
|
||||
<action name="mokodolijoomshop.settings.manage" title="COM_MOKODOLIJOOMSHOP_ACL_SETTINGS_MANAGE" />
|
||||
</access>
|
||||
|
||||
<updateservers>
|
||||
<server type="extension" name="MokoDoliJoomShop Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoDoliJoomShop/raw/branch/main/updates.xml</server>
|
||||
</updateservers>
|
||||
|
||||
@@ -10,106 +10,12 @@ COM_MOKODOLIJOOMSHOP_CHECKOUT="Checkout"
|
||||
COM_MOKODOLIJOOMSHOP_ADD_TO_CART="Add to Cart"
|
||||
COM_MOKODOLIJOOMSHOP_VIEW_CART="View Cart"
|
||||
COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT="Proceed to Checkout"
|
||||
COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING="Continue Shopping"
|
||||
COM_MOKODOLIJOOMSHOP_CART_EMPTY="Your cart is empty."
|
||||
COM_MOKODOLIJOOMSHOP_CART_ITEM_ADDED="Item added to cart."
|
||||
COM_MOKODOLIJOOMSHOP_CART_ITEM_REMOVED="Item removed from cart."
|
||||
COM_MOKODOLIJOOMSHOP_CART_ADD_FAILED="Unable to add item to cart. Product may be out of stock."
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_PLACED="Your order has been placed successfully."
|
||||
COM_MOKODOLIJOOMSHOP_PRICE="Price"
|
||||
COM_MOKODOLIJOOMSHOP_PRICE_HT="Price (excl. tax)"
|
||||
COM_MOKODOLIJOOMSHOP_QUANTITY="Quantity"
|
||||
COM_MOKODOLIJOOMSHOP_SUBTOTAL="Subtotal"
|
||||
COM_MOKODOLIJOOMSHOP_TAX="Tax"
|
||||
COM_MOKODOLIJOOMSHOP_TOTAL="Total"
|
||||
COM_MOKODOLIJOOMSHOP_IN_STOCK="In Stock"
|
||||
COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK="Out of Stock"
|
||||
COM_MOKODOLIJOOMSHOP_AVAILABLE="available"
|
||||
COM_MOKODOLIJOOMSHOP_NO_PRODUCTS="No products found."
|
||||
COM_MOKODOLIJOOMSHOP_NO_IMAGE="No image available"
|
||||
COM_MOKODOLIJOOMSHOP_DESCRIPTION="Description"
|
||||
COM_MOKODOLIJOOMSHOP_RELATED_PRODUCTS="Related Products"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_REF="Reference"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL="Product"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_DETAILS="Billing Details"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_NAME="Full Name"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_EMAIL="Email Address"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_ADDRESS="Address"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_TOWN="City"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_ZIP="Postal Code"
|
||||
COM_MOKODOLIJOOMSHOP_BILLING_PHONE="Phone"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_NOTES="Order Notes"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_NOTES_PLACEHOLDER="Any special instructions for your order..."
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_SUMMARY="Order Summary"
|
||||
COM_MOKODOLIJOOMSHOP_PLACE_ORDER="Place Order"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_REF="Order Reference"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF="Invoice Reference"
|
||||
COM_MOKODOLIJOOMSHOP_NO_ORDER_DATA="No order information available."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED="You must be logged in to checkout."
|
||||
COM_MOKODOLIJOOMSHOP_CHECKOUT_STOCK_ERROR="Some items in your cart are no longer available in the requested quantity."
|
||||
COM_MOKODOLIJOOMSHOP_CHECKOUT_FAILED="Unable to process your order. Please try again."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORIES="Categories"
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY="Products by Category"
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY_DESC="Display products from a specific Dolibarr category."
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY_OPTIONS="Category Options"
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY_ID="Category ID"
|
||||
COM_MOKODOLIJOOMSHOP_CATEGORY_ID_DESC="The Dolibarr product category ID to display."
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC="Display the full product catalog."
|
||||
COM_MOKODOLIJOOMSHOP_CART_DESC="Display the shopping cart."
|
||||
COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC="Display the checkout form."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_LOW_STOCK="Low Stock"
|
||||
COM_MOKODOLIJOOMSHOP_BACKORDER="Available on Backorder"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_MY_ORDERS="My Orders"
|
||||
COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC="Display order history for the logged-in user."
|
||||
COM_MOKODOLIJOOMSHOP_ORDERS_LOGIN_REQUIRED="Please log in to view your order history."
|
||||
COM_MOKODOLIJOOMSHOP_VIEW_DETAIL="View"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_DATE="Date"
|
||||
COM_MOKODOLIJOOMSHOP_ORDER_STATUS="Status"
|
||||
COM_MOKODOLIJOOMSHOP_NO_ORDERS="You have no orders yet."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_SEARCH="Search"
|
||||
COM_MOKODOLIJOOMSHOP_SEARCH_PLACEHOLDER="Search products..."
|
||||
COM_MOKODOLIJOOMSHOP_SORT_BY="Sort by"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_REF_ASC="Reference (A-Z)"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_REF_DESC="Reference (Z-A)"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_PRICE_ASC="Price (Low to High)"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_PRICE_DESC="Price (High to Low)"
|
||||
COM_MOKODOLIJOOMSHOP_SORT_NEWEST="Newest"
|
||||
COM_MOKODOLIJOOMSHOP_FILTER_PRICE="Price Range"
|
||||
COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING="Continue Shopping"
|
||||
COM_MOKODOLIJOOMSHOP_USE_GLOBAL="Use Global"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC="Display a single product detail page."
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_OPTIONS="Product Options"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_ID="Product ID"
|
||||
COM_MOKODOLIJOOMSHOP_PRODUCT_ID_DESC="The Dolibarr product ID to display."
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_SELECT_VARIANT="Select %s"
|
||||
COM_MOKODOLIJOOMSHOP_VARIANT_UNAVAILABLE="Variant unavailable"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_WISHLIST="Wishlist"
|
||||
COM_MOKODOLIJOOMSHOP_ADD_TO_WISHLIST="Add to Wishlist"
|
||||
COM_MOKODOLIJOOMSHOP_REMOVE_FROM_WISHLIST="Remove from Wishlist"
|
||||
COM_MOKODOLIJOOMSHOP_WISHLIST_EMPTY="Your wishlist is empty."
|
||||
COM_MOKODOLIJOOMSHOP_WISHLIST_ADDED="Item added to wishlist."
|
||||
COM_MOKODOLIJOOMSHOP_MOVE_TO_CART="Move to Cart"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_COUPON_CODE="Coupon Code"
|
||||
COM_MOKODOLIJOOMSHOP_APPLY_COUPON="Apply"
|
||||
COM_MOKODOLIJOOMSHOP_COUPON_APPLIED="Discount applied: %s"
|
||||
COM_MOKODOLIJOOMSHOP_COUPON_INVALID="Invalid coupon code."
|
||||
COM_MOKODOLIJOOMSHOP_DISCOUNT="Discount"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_MY_ADDRESSES="My Addresses"
|
||||
COM_MOKODOLIJOOMSHOP_ADD_ADDRESS="Add Address"
|
||||
COM_MOKODOLIJOOMSHOP_EDIT_ADDRESS="Edit Address"
|
||||
COM_MOKODOLIJOOMSHOP_DEFAULT_ADDRESS="Default"
|
||||
COM_MOKODOLIJOOMSHOP_ADDRESS_LABEL="Label (e.g., Home, Office)"
|
||||
|
||||
COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND="Invoice PDF not available."
|
||||
COM_MOKODOLIJOOMSHOP_DOWNLOAD_INVOICE="Download Invoice"
|
||||
|
||||
@@ -8,5 +8,4 @@
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
// Site service provider — component registration is handled by the admin provider.
|
||||
// This file must exist but no additional services are needed for the site side.
|
||||
// Site service provider — component registration handled by admin provider
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
/**
|
||||
* Cart controller — handles add, update, and remove actions.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CartController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Add a product to the cart.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function add(): void
|
||||
{
|
||||
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$productId = $this->input->getInt('product_id', 0);
|
||||
$quantity = $this->input->getInt('quantity', 1);
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||
$model = $this->getModel('Cart');
|
||||
|
||||
if ($model->addItem($productId, $quantity))
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ITEM_ADDED'), 'success');
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ADD_FAILED'), 'error');
|
||||
}
|
||||
|
||||
$return = $this->input->getBase64('return', '');
|
||||
|
||||
if ($return)
|
||||
{
|
||||
$this->setRedirect(base64_decode($return));
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cart item quantity.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$cartItemId = $this->input->getInt('cart_item_id', 0);
|
||||
$quantity = $this->input->getInt('quantity', 1);
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||
$model = $this->getModel('Cart');
|
||||
$model->updateItemQuantity($cartItemId, $quantity);
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a cart item.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function remove(): void
|
||||
{
|
||||
Session::checkToken('request') or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$cartItemId = $this->input->getInt('cart_item_id', 0);
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $model */
|
||||
$model = $this->getModel('Cart');
|
||||
$model->removeItem($cartItemId);
|
||||
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_ITEM_REMOVED'), 'success');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/**
|
||||
* Checkout controller — processes the checkout form submission.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CheckoutController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Process the checkout form.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function process(): void
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CheckoutModel $checkoutModel */
|
||||
$checkoutModel = $this->getModel('Checkout');
|
||||
|
||||
if (!$checkoutModel->canCheckout())
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED'), 'warning');
|
||||
$this->setRedirect(Route::_('index.php?option=com_users&view=login', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $cartModel */
|
||||
$cartModel = $this->getModel('Cart');
|
||||
$cartItems = $cartModel->getItems();
|
||||
|
||||
if (empty($cartItems))
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'), 'warning');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate stock before proceeding
|
||||
$stockProblems = $cartModel->validateStock();
|
||||
|
||||
if (!empty($stockProblems))
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_STOCK_ERROR'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=cart', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect billing data from form
|
||||
$billingData = [
|
||||
'name' => $this->input->getString('billing_name', ''),
|
||||
'email' => $this->input->getString('billing_email', ''),
|
||||
'address' => $this->input->getString('billing_address', ''),
|
||||
'town' => $this->input->getString('billing_town', ''),
|
||||
'zip' => $this->input->getString('billing_zip', ''),
|
||||
'phone' => $this->input->getString('billing_phone', ''),
|
||||
'notes' => $this->input->getString('order_notes', ''),
|
||||
];
|
||||
|
||||
$totals = $cartModel->getTotals();
|
||||
$result = $checkoutModel->processCheckout($billingData, $cartItems, $totals);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_FAILED'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=checkout', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the cart on success
|
||||
$cartModel->clearCart();
|
||||
|
||||
// Store result in session for confirmation page
|
||||
$session = $this->app->getSession();
|
||||
$session->set('mokodolijoomshop.order_result', $result);
|
||||
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_ORDER_PLACED'), 'success');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokodolijoomshop&view=checkout&layout=confirmation', false));
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Invoice controller — handles PDF download for frontend users.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class InvoiceController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Download an invoice PDF.
|
||||
*
|
||||
* Streams the PDF directly from Dolibarr to the browser.
|
||||
* Access is restricted to the order owner.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function download(): void
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$invoiceId = $this->input->getInt('invoice_id', 0);
|
||||
|
||||
if ($userId === 0 || $invoiceId === 0)
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
|
||||
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true);
|
||||
$query->select($db->quoteName('invoice_ref'))
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||
$db->setQuery($query);
|
||||
$invoiceRef = $db->loadResult();
|
||||
|
||||
if ($invoiceRef === null)
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
|
||||
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch PDF from Dolibarr
|
||||
$client = new DolibarrClient();
|
||||
$docs = $client->get('/documents', [
|
||||
'modulepart' => 'invoice',
|
||||
'id' => $invoiceId,
|
||||
]);
|
||||
|
||||
$pdfDoc = null;
|
||||
|
||||
if (!empty($docs))
|
||||
{
|
||||
foreach ($docs as $doc)
|
||||
{
|
||||
if (isset($doc['relativename']) && str_ends_with($doc['relativename'], '.pdf'))
|
||||
{
|
||||
$pdfDoc = $doc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($pdfDoc === null)
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND'), 'warning');
|
||||
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Download content
|
||||
$download = $client->get('/documents/download', [
|
||||
'modulepart' => 'invoice',
|
||||
'original_file' => $pdfDoc['relativename'],
|
||||
]);
|
||||
|
||||
if (empty($download['content']))
|
||||
{
|
||||
$this->app->enqueueMessage(Text::_('COM_MOKODOLIJOOMSHOP_INVOICE_NOT_FOUND'), 'warning');
|
||||
$this->setRedirect('index.php?option=com_mokodolijoomshop&view=orders');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pdfContent = base64_decode($download['content']);
|
||||
$filename = $invoiceRef . '.pdf';
|
||||
|
||||
// Stream PDF to browser
|
||||
$this->app->setHeader('Content-Type', 'application/pdf');
|
||||
$this->app->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||
$this->app->setHeader('Content-Length', (string) \strlen($pdfContent));
|
||||
$this->app->sendHeaders();
|
||||
|
||||
echo $pdfContent;
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Search controller — provides AJAX product search and filtering.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class SearchController extends BaseController
|
||||
{
|
||||
/**
|
||||
* AJAX search endpoint.
|
||||
*
|
||||
* Accepts: q (text), category_id, price_min, price_max, sort, page.
|
||||
* Returns JSON array of products.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function search(): void
|
||||
{
|
||||
$client = new DolibarrClient();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$perPage = (int) $params->get('products_per_page', 12);
|
||||
|
||||
$q = $this->input->getString('q', '');
|
||||
$categoryId = $this->input->getInt('category_id', 0);
|
||||
$priceMin = $this->input->getFloat('price_min', 0);
|
||||
$priceMax = $this->input->getFloat('price_max', 0);
|
||||
$sort = $this->input->getString('sort', 'ref_asc');
|
||||
$page = $this->input->getInt('page', 0);
|
||||
|
||||
// Build sort parameters
|
||||
$sortMap = [
|
||||
'ref_asc' => ['t.ref', 'ASC'],
|
||||
'ref_desc' => ['t.ref', 'DESC'],
|
||||
'label_asc' => ['t.label', 'ASC'],
|
||||
'label_desc' => ['t.label', 'DESC'],
|
||||
'price_asc' => ['t.price', 'ASC'],
|
||||
'price_desc' => ['t.price', 'DESC'],
|
||||
'newest' => ['t.datec', 'DESC'],
|
||||
];
|
||||
|
||||
$sortField = $sortMap[$sort][0] ?? 't.ref';
|
||||
$sortOrder = $sortMap[$sort][1] ?? 'ASC';
|
||||
|
||||
$query = [
|
||||
'sortfield' => $sortField,
|
||||
'sortorder' => $sortOrder,
|
||||
'limit' => $perPage,
|
||||
'page' => $page,
|
||||
];
|
||||
|
||||
if ($categoryId > 0)
|
||||
{
|
||||
$query['category'] = $categoryId;
|
||||
}
|
||||
|
||||
// Build sqlfilters for text search and price range
|
||||
$filters = [];
|
||||
|
||||
if (!empty($q))
|
||||
{
|
||||
$escaped = addslashes($q);
|
||||
$filters[] = "(t.label:like:'%{$escaped}%') or (t.ref:like:'%{$escaped}%') or (t.description:like:'%{$escaped}%')";
|
||||
}
|
||||
|
||||
if ($priceMin > 0)
|
||||
{
|
||||
$filters[] = "(t.price:>=:{$priceMin})";
|
||||
}
|
||||
|
||||
if ($priceMax > 0)
|
||||
{
|
||||
$filters[] = "(t.price:<=:{$priceMax})";
|
||||
}
|
||||
|
||||
if (!empty($filters))
|
||||
{
|
||||
$query['sqlfilters'] = implode(' and ', $filters);
|
||||
}
|
||||
|
||||
$products = $client->get('/products', $query);
|
||||
|
||||
// Return JSON response
|
||||
$this->app->setHeader('Content-Type', 'application/json');
|
||||
$this->app->sendHeaders();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'products' => $products ?? [],
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Coupon/discount code helper — validates codes against Dolibarr discount rules.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CouponHelper
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a coupon code and return the discount details.
|
||||
*
|
||||
* @param string $code Coupon code entered by user.
|
||||
* @param int $thirdpartyId Customer thirdparty ID (for customer-specific discounts).
|
||||
*
|
||||
* @return array|null Discount data or null if invalid.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function validate(string $code, int $thirdpartyId = 0): ?array
|
||||
{
|
||||
if (empty(trim($code)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search for discount rules matching this code in Dolibarr
|
||||
// Dolibarr stores available discounts per thirdparty
|
||||
if ($thirdpartyId > 0)
|
||||
{
|
||||
$discounts = $this->client->get('/thirdparties/' . $thirdpartyId . '/availablediscounts');
|
||||
|
||||
if (!empty($discounts))
|
||||
{
|
||||
foreach ($discounts as $discount)
|
||||
{
|
||||
if (($discount['description'] ?? '') === $code || ($discount['ref'] ?? '') === $code)
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($discount['id'] ?? 0),
|
||||
'type' => !empty($discount['percent']) ? 'percent' : 'fixed',
|
||||
'value' => (float) ($discount['percent'] ?? $discount['amount_ttc'] ?? 0),
|
||||
'amount_ht' => (float) ($discount['amount_ht'] ?? 0),
|
||||
'amount_ttc' => (float) ($discount['amount_ttc'] ?? 0),
|
||||
'description' => $discount['description'] ?? $code,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a discount to a cart total.
|
||||
*
|
||||
* @param array $discount Discount data from validate().
|
||||
* @param float $subtotal Cart subtotal before discount.
|
||||
*
|
||||
* @return float Discount amount to subtract.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function calculateDiscount(array $discount, float $subtotal): float
|
||||
{
|
||||
if ($discount['type'] === 'percent')
|
||||
{
|
||||
return round($subtotal * ($discount['value'] / 100), 4);
|
||||
}
|
||||
|
||||
// Fixed amount — don't exceed subtotal
|
||||
return min($discount['amount_ttc'], $subtotal);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
/**
|
||||
* Stock display helper — determines stock status and badge rendering.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class StockHelper
|
||||
{
|
||||
public const STATUS_IN_STOCK = 'in_stock';
|
||||
public const STATUS_LOW_STOCK = 'low_stock';
|
||||
public const STATUS_OUT = 'out_of_stock';
|
||||
|
||||
/**
|
||||
* Determine stock status for a given quantity.
|
||||
*
|
||||
* @param float $stockQty Stock quantity.
|
||||
*
|
||||
* @return string One of the STATUS_ constants.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getStatus(float $stockQty): string
|
||||
{
|
||||
if ($stockQty <= 0)
|
||||
{
|
||||
return self::STATUS_OUT;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$threshold = (int) $params->get('low_stock_threshold', 5);
|
||||
|
||||
if ($stockQty <= $threshold)
|
||||
{
|
||||
return self::STATUS_LOW_STOCK;
|
||||
}
|
||||
|
||||
return self::STATUS_IN_STOCK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Bootstrap badge for stock status.
|
||||
*
|
||||
* @param float $stockQty Stock quantity.
|
||||
* @param bool $showQty Whether to show the numeric quantity.
|
||||
*
|
||||
* @return string HTML badge markup.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function renderBadge(float $stockQty, bool $showQty = false): string
|
||||
{
|
||||
$status = self::getStatus($stockQty);
|
||||
|
||||
switch ($status)
|
||||
{
|
||||
case self::STATUS_IN_STOCK:
|
||||
$class = 'bg-success';
|
||||
$text = Text::_('COM_MOKODOLIJOOMSHOP_IN_STOCK');
|
||||
break;
|
||||
|
||||
case self::STATUS_LOW_STOCK:
|
||||
$class = 'bg-warning text-dark';
|
||||
$text = Text::_('COM_MOKODOLIJOOMSHOP_LOW_STOCK');
|
||||
break;
|
||||
|
||||
case self::STATUS_OUT:
|
||||
default:
|
||||
$class = 'bg-danger';
|
||||
$text = Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK');
|
||||
break;
|
||||
}
|
||||
|
||||
if ($showQty && $stockQty > 0)
|
||||
{
|
||||
$text .= ' (' . (int) $stockQty . ')';
|
||||
}
|
||||
|
||||
return '<span class="badge ' . $class . '">' . $text . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if add-to-cart should be enabled.
|
||||
*
|
||||
* @param float $stockQty Stock quantity.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function canAddToCart(float $stockQty): bool
|
||||
{
|
||||
if ($stockQty > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if backorders are allowed
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (bool) $params->get('allow_backorder', false);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
|
||||
/**
|
||||
* Tax calculation and display helper.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class TaxHelper
|
||||
{
|
||||
/**
|
||||
* Get the configured tax display mode.
|
||||
*
|
||||
* @return string 'ttc' (incl. tax), 'ht' (excl. tax), or 'both'.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getDisplayMode(): string
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return $params->get('tax_display', 'ttc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tax is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (bool) $params->get('tax_enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax breakdown grouped by rate from cart items.
|
||||
*
|
||||
* @param array $cartItems Cart items with 'unit_price', 'quantity', 'tax_rate'.
|
||||
*
|
||||
* @return array Array of [rate => amount], e.g., [20.0 => 40.00, 5.0 => 2.50].
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function getGroupedTax(array $cartItems): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($cartItems as $item)
|
||||
{
|
||||
$rate = (float) ($item['tax_rate'] ?? 0);
|
||||
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||
$taxAmount = $lineTotal * ($rate / 100);
|
||||
|
||||
if ($rate > 0)
|
||||
{
|
||||
if (!isset($grouped[$rate]))
|
||||
{
|
||||
$grouped[$rate] = 0.0;
|
||||
}
|
||||
|
||||
$grouped[$rate] += $taxAmount;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($grouped);
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a price for display based on the tax display mode.
|
||||
*
|
||||
* @param float $priceHT Price excluding tax.
|
||||
* @param float $priceTTC Price including tax.
|
||||
* @param string $currency Currency code.
|
||||
*
|
||||
* @return string Formatted price string.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function formatPrice(float $priceHT, float $priceTTC, string $currency): string
|
||||
{
|
||||
$mode = self::getDisplayMode();
|
||||
|
||||
switch ($mode)
|
||||
{
|
||||
case 'ht':
|
||||
return number_format($priceHT, 2) . ' ' . $currency . ' HT';
|
||||
|
||||
case 'both':
|
||||
return number_format($priceTTC, 2) . ' ' . $currency
|
||||
. ' <small class="text-muted">(' . number_format($priceHT, 2) . ' HT)</small>';
|
||||
|
||||
case 'ttc':
|
||||
default:
|
||||
return number_format($priceTTC, 2) . ' ' . $currency;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate totals from cart items including tax breakdown.
|
||||
*
|
||||
* @param array $cartItems Cart items.
|
||||
*
|
||||
* @return array{subtotal_ht: float, tax_total: float, total_ttc: float, tax_grouped: array}
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function calculateTotals(array $cartItems): array
|
||||
{
|
||||
$subtotalHT = 0.0;
|
||||
$taxTotal = 0.0;
|
||||
$grouped = [];
|
||||
|
||||
foreach ($cartItems as $item)
|
||||
{
|
||||
$rate = (float) ($item['tax_rate'] ?? 0);
|
||||
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||
$lineTax = $lineTotal * ($rate / 100);
|
||||
|
||||
$subtotalHT += $lineTotal;
|
||||
$taxTotal += $lineTax;
|
||||
|
||||
if ($rate > 0)
|
||||
{
|
||||
if (!isset($grouped[$rate]))
|
||||
{
|
||||
$grouped[$rate] = 0.0;
|
||||
}
|
||||
|
||||
$grouped[$rate] += $lineTax;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($grouped);
|
||||
|
||||
return [
|
||||
'subtotal_ht' => $subtotalHT,
|
||||
'tax_total' => $taxTotal,
|
||||
'total_ttc' => $subtotalHT + $taxTotal,
|
||||
'tax_grouped' => $grouped,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Product variant helper — handles Dolibarr product variants/combinations.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class VariantHelper
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variants for a product.
|
||||
*
|
||||
* @param int $productId Parent product ID.
|
||||
*
|
||||
* @return array Array of variant data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getVariants(int $productId): array
|
||||
{
|
||||
$variants = $this->client->get('/products/' . $productId . '/variants');
|
||||
|
||||
if ($variants === null || !\is_array($variants))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a product has variants.
|
||||
*
|
||||
* @param int $productId Product ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function hasVariants(int $productId): bool
|
||||
{
|
||||
$variants = $this->getVariants($productId);
|
||||
|
||||
return !empty($variants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse variants into grouped attribute selectors.
|
||||
*
|
||||
* Returns a structure like:
|
||||
* [
|
||||
* 'Color' => ['Red' => [...], 'Blue' => [...]],
|
||||
* 'Size' => ['S' => [...], 'M' => [...], 'L' => [...]],
|
||||
* ]
|
||||
*
|
||||
* @param array $variants Raw variants from Dolibarr.
|
||||
*
|
||||
* @return array Grouped attributes.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function groupByAttribute(array $variants): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($variants as $variant)
|
||||
{
|
||||
$attributes = $variant['attributes'] ?? [];
|
||||
|
||||
foreach ($attributes as $attr)
|
||||
{
|
||||
$attrName = $attr['attribute'] ?? $attr['ref'] ?? 'Option';
|
||||
$attrValue = $attr['value'] ?? $attr['ref_ext'] ?? '';
|
||||
|
||||
if (!isset($grouped[$attrName]))
|
||||
{
|
||||
$grouped[$attrName] = [];
|
||||
}
|
||||
|
||||
if (!isset($grouped[$attrName][$attrValue]))
|
||||
{
|
||||
$grouped[$attrName][$attrValue] = [];
|
||||
}
|
||||
|
||||
$grouped[$attrName][$attrValue][] = [
|
||||
'variant_id' => (int) ($variant['fk_product_child'] ?? $variant['id'] ?? 0),
|
||||
'ref' => $variant['ref'] ?? '',
|
||||
'price_diff' => (float) ($variant['variation_price'] ?? 0),
|
||||
'price_type' => $variant['variation_price_percentage'] ?? false,
|
||||
'stock' => (float) ($variant['stock_reel'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build JSON data for variant selectors (consumed by frontend JS).
|
||||
*
|
||||
* @param int $productId Parent product ID.
|
||||
* @param float $basePrice Base product price.
|
||||
*
|
||||
* @return array Variant config for JSON encoding.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getVariantConfig(int $productId, float $basePrice): array
|
||||
{
|
||||
$variants = $this->getVariants($productId);
|
||||
|
||||
if (empty($variants))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = [
|
||||
'base_price' => $basePrice,
|
||||
'variants' => [],
|
||||
];
|
||||
|
||||
foreach ($variants as $variant)
|
||||
{
|
||||
$childId = (int) ($variant['fk_product_child'] ?? $variant['id'] ?? 0);
|
||||
$priceDiff = (float) ($variant['variation_price'] ?? 0);
|
||||
$isPercent = !empty($variant['variation_price_percentage']);
|
||||
$finalPrice = $isPercent
|
||||
? $basePrice * (1 + $priceDiff / 100)
|
||||
: $basePrice + $priceDiff;
|
||||
|
||||
$config['variants'][] = [
|
||||
'id' => $childId,
|
||||
'ref' => $variant['ref'] ?? '',
|
||||
'attributes' => $variant['attributes'] ?? [],
|
||||
'price' => round($finalPrice, 4),
|
||||
'price_diff' => $priceDiff,
|
||||
'stock' => (float) ($variant['stock_reel'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
/**
|
||||
* Shipping address model — manages user address book.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class AddressModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get all addresses for the current user.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getAddresses(): array
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->order($db->quoteName('is_default') . ' DESC, ' . $db->quoteName('label') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default address for the current user.
|
||||
*
|
||||
* @return array|null
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getDefaultAddress(): ?array
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('is_default') . ' = 1');
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
$result = $db->loadAssoc();
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an address.
|
||||
*
|
||||
* @param array $data Address data.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function saveAddress(array $data): bool
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// If setting as default, clear other defaults first
|
||||
if (!empty($data['is_default']))
|
||||
{
|
||||
$clear = $db->getQuery(true);
|
||||
$clear->update($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->set($db->quoteName('is_default') . ' = 0')
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
$db->setQuery($clear);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
$id = (int) ($data['id'] ?? 0);
|
||||
|
||||
if ($id > 0)
|
||||
{
|
||||
// Update existing
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->set($db->quoteName('label') . ' = ' . $db->quote($data['label'] ?? ''))
|
||||
->set($db->quoteName('name') . ' = ' . $db->quote($data['name'] ?? ''))
|
||||
->set($db->quoteName('address') . ' = ' . $db->quote($data['address'] ?? ''))
|
||||
->set($db->quoteName('town') . ' = ' . $db->quote($data['town'] ?? ''))
|
||||
->set($db->quoteName('zip') . ' = ' . $db->quote($data['zip'] ?? ''))
|
||||
->set($db->quoteName('country_code') . ' = ' . $db->quote($data['country_code'] ?? ''))
|
||||
->set($db->quoteName('phone') . ' = ' . $db->quote($data['phone'] ?? ''))
|
||||
->set($db->quoteName('is_default') . ' = ' . (int) ($data['is_default'] ?? 0))
|
||||
->where($db->quoteName('id') . ' = ' . $id)
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
// Insert new
|
||||
$query = $db->getQuery(true);
|
||||
$query->insert($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->columns(['user_id', 'label', 'name', 'address', 'town', 'zip', 'country_code', 'phone', 'is_default'])
|
||||
->values(implode(',', [
|
||||
$userId,
|
||||
$db->quote($data['label'] ?? ''),
|
||||
$db->quote($data['name'] ?? ''),
|
||||
$db->quote($data['address'] ?? ''),
|
||||
$db->quote($data['town'] ?? ''),
|
||||
$db->quote($data['zip'] ?? ''),
|
||||
$db->quote($data['country_code'] ?? ''),
|
||||
$db->quote($data['phone'] ?? ''),
|
||||
(int) ($data['is_default'] ?? 0),
|
||||
]));
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an address.
|
||||
*
|
||||
* @param int $addressId Address ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function deleteAddress(int $addressId): bool
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_addresses'))
|
||||
->where($db->quoteName('id') . ' = ' . $addressId)
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Shopping cart model — session-based with DB persistence.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CartModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get the current session identifier.
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getSessionId(): string
|
||||
{
|
||||
return Factory::getApplication()->getSession()->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user ID (0 for guests).
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getUserId(): int
|
||||
{
|
||||
return (int) Factory::getApplication()->getIdentity()->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cart items for the current user/session.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_cart'));
|
||||
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($this->getSessionId()));
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('created') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the cart.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
* @param int $quantity Quantity to add.
|
||||
*
|
||||
* @return bool True on success.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function addItem(int $productId, int $quantity = 1): bool
|
||||
{
|
||||
// Fetch product info from Dolibarr
|
||||
$client = new DolibarrClient();
|
||||
$product = $client->get('/products/' . $productId);
|
||||
|
||||
if ($product === null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate stock
|
||||
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||
|
||||
if ($stockReel <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$userId = $this->getUserId();
|
||||
$sessionId = $this->getSessionId();
|
||||
|
||||
// Check if this product already exists in cart
|
||||
$existing = $this->findCartItem($productId);
|
||||
|
||||
if ($existing)
|
||||
{
|
||||
$newQty = (int) $existing['quantity'] + $quantity;
|
||||
|
||||
if ($newQty > $stockReel)
|
||||
{
|
||||
$newQty = (int) $stockReel;
|
||||
}
|
||||
|
||||
return $this->updateItemQuantity((int) $existing['id'], $newQty);
|
||||
}
|
||||
|
||||
// Clamp quantity to stock
|
||||
if ($quantity > $stockReel)
|
||||
{
|
||||
$quantity = (int) $stockReel;
|
||||
}
|
||||
|
||||
$table = $this->getTable('Cart', 'Administrator');
|
||||
$data = [
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $userId,
|
||||
'dolibarr_product_id' => $productId,
|
||||
'product_ref' => $product['ref'] ?? '',
|
||||
'product_label' => $product['label'] ?? '',
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => (float) ($product['price_ttc'] ?? $product['price'] ?? 0),
|
||||
'tax_rate' => (float) ($product['tva_tx'] ?? 0),
|
||||
];
|
||||
|
||||
$table->bind($data);
|
||||
|
||||
if (!$table->check())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return $table->store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quantity of a cart item.
|
||||
*
|
||||
* @param int $cartItemId Cart row ID.
|
||||
* @param int $quantity New quantity.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function updateItemQuantity(int $cartItemId, int $quantity): bool
|
||||
{
|
||||
if ($quantity < 1)
|
||||
{
|
||||
return $this->removeItem($cartItemId);
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->set($db->quoteName('quantity') . ' = ' . $quantity)
|
||||
->where($db->quoteName('id') . ' = ' . $cartItemId);
|
||||
|
||||
$this->addOwnerCondition($query);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the cart.
|
||||
*
|
||||
* @param int $cartItemId Cart row ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function removeItem(int $cartItemId): bool
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->where($db->quoteName('id') . ' = ' . $cartItemId);
|
||||
|
||||
$this->addOwnerCondition($query);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items from the current cart.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function clearCart(): bool
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_cart'));
|
||||
$this->addOwnerCondition($query);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge guest session cart into the logged-in user's cart.
|
||||
*
|
||||
* @param string $sessionId Guest session ID.
|
||||
* @param int $userId Logged-in user ID.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function mergeGuestCart(string $sessionId, int $userId): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
// Update guest cart items to belong to the user
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->set($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId))
|
||||
->where($db->quoteName('user_id') . ' = 0');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cart items older than the specified number of hours.
|
||||
*
|
||||
* @param int $hours Age threshold in hours.
|
||||
*
|
||||
* @return int Number of rows deleted.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function cleanExpired(int $hours = 72): int
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$cutoff = Factory::getDate('-' . $hours . ' hours')->toSql();
|
||||
$query = $db->getQuery(true);
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->where($db->quoteName('modified') . ' < ' . $db->quote($cutoff))
|
||||
->where($db->quoteName('user_id') . ' = 0');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
return $db->getAffectedRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cart totals.
|
||||
*
|
||||
* @return array{subtotal: float, tax: float, total: float, count: int}
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getTotals(): array
|
||||
{
|
||||
$items = $this->getItems();
|
||||
$subtotal = 0.0;
|
||||
$tax = 0.0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
||||
$lineTax = $lineTotal * ((float) $item['tax_rate'] / 100);
|
||||
$subtotal += $lineTotal;
|
||||
$tax += $lineTax;
|
||||
$count += (int) $item['quantity'];
|
||||
}
|
||||
|
||||
return [
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $subtotal + $tax,
|
||||
'count' => $count,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stock levels for all cart items against Dolibarr.
|
||||
*
|
||||
* @return array Array of items with insufficient stock: [product_id => available_qty].
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function validateStock(): array
|
||||
{
|
||||
$client = new DolibarrClient();
|
||||
$items = $this->getItems();
|
||||
$problems = [];
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$product = $client->get('/products/' . (int) $item['dolibarr_product_id']);
|
||||
|
||||
if ($product === null)
|
||||
{
|
||||
$problems[(int) $item['dolibarr_product_id']] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||
|
||||
if ((int) $item['quantity'] > $stockReel)
|
||||
{
|
||||
$problems[(int) $item['dolibarr_product_id']] = $stockReel;
|
||||
}
|
||||
}
|
||||
|
||||
return $problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing cart item by product ID for the current user/session.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return array|null
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function findCartItem(int $productId): ?array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_cart'))
|
||||
->where($db->quoteName('dolibarr_product_id') . ' = ' . $productId);
|
||||
|
||||
$this->addOwnerCondition($query);
|
||||
$db->setQuery($query);
|
||||
$result = $db->loadAssoc();
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add user/session ownership condition to a query.
|
||||
*
|
||||
* @param \Joomla\Database\DatabaseQuery $query Query to modify.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function addOwnerCondition($query): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($this->getSessionId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Category model — fetches product categories and their products from Dolibarr.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CategoryModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single category by ID.
|
||||
*
|
||||
* @param int|null $id Category ID, or null to read from input.
|
||||
*
|
||||
* @return array|null
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCategory(?int $id = null): ?array
|
||||
{
|
||||
if ($id === null)
|
||||
{
|
||||
$id = Factory::getApplication()->input->getInt('id', 0);
|
||||
}
|
||||
|
||||
if ($id <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->client->get('/categories/' . $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product categories as a flat list.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getAllCategories(): array
|
||||
{
|
||||
$categories = $this->client->get('/categories', [
|
||||
'sortfield' => 't.label',
|
||||
'sortorder' => 'ASC',
|
||||
'type' => 'product',
|
||||
'limit' => 200,
|
||||
]);
|
||||
|
||||
return $categories ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a hierarchical category tree.
|
||||
*
|
||||
* @return array Nested array with 'children' key.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCategoryTree(): array
|
||||
{
|
||||
$flat = $this->getAllCategories();
|
||||
$tree = [];
|
||||
$map = [];
|
||||
|
||||
// Index by ID
|
||||
foreach ($flat as $cat)
|
||||
{
|
||||
$cat['children'] = [];
|
||||
$map[(int) $cat['id']] = $cat;
|
||||
}
|
||||
|
||||
// Build tree
|
||||
foreach ($map as $id => &$cat)
|
||||
{
|
||||
$parentId = (int) ($cat['fk_parent'] ?? 0);
|
||||
|
||||
if ($parentId > 0 && isset($map[$parentId]))
|
||||
{
|
||||
$map[$parentId]['children'][] = &$cat;
|
||||
}
|
||||
else
|
||||
{
|
||||
$tree[] = &$cat;
|
||||
}
|
||||
}
|
||||
|
||||
unset($cat);
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products belonging to a category.
|
||||
*
|
||||
* @param int $categoryId Category ID.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCategoryProducts(int $categoryId): array
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$perPage = (int) $params->get('products_per_page', 12);
|
||||
$app = Factory::getApplication();
|
||||
$page = $app->input->getInt('page', 0);
|
||||
|
||||
$products = $this->client->get('/products', [
|
||||
'sortfield' => 't.ref',
|
||||
'sortorder' => 'ASC',
|
||||
'limit' => $perPage,
|
||||
'page' => $page,
|
||||
'category' => $categoryId,
|
||||
]);
|
||||
|
||||
return $products ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build breadcrumb trail for a category.
|
||||
*
|
||||
* @param int $categoryId Category ID.
|
||||
*
|
||||
* @return array Array of [id, label] from root to current.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getBreadcrumbs(int $categoryId): array
|
||||
{
|
||||
$crumbs = [];
|
||||
$visited = [];
|
||||
$current = $categoryId;
|
||||
|
||||
while ($current > 0 && !isset($visited[$current]))
|
||||
{
|
||||
$visited[$current] = true;
|
||||
$cat = $this->client->get('/categories/' . $current);
|
||||
|
||||
if ($cat === null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
array_unshift($crumbs, [
|
||||
'id' => (int) $cat['id'],
|
||||
'label' => $cat['label'] ?? '',
|
||||
]);
|
||||
|
||||
$current = (int) ($cat['fk_parent'] ?? 0);
|
||||
}
|
||||
|
||||
return $crumbs;
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Service\CustomerSyncService;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Service\OrderService;
|
||||
|
||||
/**
|
||||
* Checkout model — handles the full checkout process.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class CheckoutModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Determine if the current user can checkout (based on checkout_mode config).
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function canCheckout(): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$mode = $params->get('checkout_mode', 'both');
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($mode === 'registered' && $userId === 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured checkout mode.
|
||||
*
|
||||
* @return string 'guest', 'registered', or 'both'.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCheckoutMode(): string
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return $params->get('checkout_mode', 'both');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the checkout.
|
||||
*
|
||||
* @param array $billingData Billing form data.
|
||||
* @param array $cartItems Cart items from CartModel.
|
||||
* @param array $totals Cart totals.
|
||||
*
|
||||
* @return array|null Order result with refs, or null on failure.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function processCheckout(array $billingData, array $cartItems, array $totals): ?array
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$customerService = new CustomerSyncService();
|
||||
$orderService = new OrderService();
|
||||
|
||||
// Resolve or create the Dolibarr thirdparty
|
||||
if ($userId > 0)
|
||||
{
|
||||
$thirdpartyId = $customerService->getOrCreateThirdparty($userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$thirdpartyId = $customerService->createGuestCustomer(
|
||||
$billingData['name'] ?? 'Guest Customer',
|
||||
$billingData['email'] ?? '',
|
||||
$billingData['address'] ?? '',
|
||||
$billingData['town'] ?? '',
|
||||
$billingData['zip'] ?? '',
|
||||
$billingData['phone'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
if ($thirdpartyId === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create order in Dolibarr
|
||||
$order = $orderService->createOrder($thirdpartyId, $cartItems, [
|
||||
'note_public' => $billingData['notes'] ?? '',
|
||||
]);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderId = (int) ($order['id'] ?? 0);
|
||||
$orderRef = $order['ref'] ?? '';
|
||||
|
||||
// Create invoice from order
|
||||
$invoice = $orderService->createInvoiceFromOrder($orderId);
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
$invoiceRef = $invoice['ref'] ?? '';
|
||||
|
||||
// Save local mapping
|
||||
$orderService->saveOrderMapping(
|
||||
$userId,
|
||||
$orderId,
|
||||
$invoiceId,
|
||||
$thirdpartyId,
|
||||
$orderRef,
|
||||
$invoiceRef,
|
||||
$totals['subtotal'],
|
||||
$totals['total']
|
||||
);
|
||||
|
||||
return [
|
||||
'order_id' => $orderId,
|
||||
'order_ref' => $orderRef,
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_ref' => $invoiceRef,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Order history model — retrieves user's orders from local mapping and Dolibarr.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class OrdersModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders for the currently logged-in user.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getUserOrders(): array
|
||||
{
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->order($db->quoteName('created') . ' DESC');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single order detail from Dolibarr.
|
||||
*
|
||||
* @param int $orderId Dolibarr order ID.
|
||||
*
|
||||
* @return array|null
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getOrderDetail(int $orderId): ?array
|
||||
{
|
||||
// Verify the order belongs to the current user
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select($db->quoteName('dolibarr_order_id'))
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('dolibarr_order_id') . ' = ' . $orderId);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ($db->loadResult() === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->client->get('/orders/' . $orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice PDF download URL from Dolibarr.
|
||||
*
|
||||
* @param int $invoiceId Dolibarr invoice ID.
|
||||
*
|
||||
* @return array|null Document info with download data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getInvoicePdf(int $invoiceId): ?array
|
||||
{
|
||||
// Verify user has access to this invoice
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
if ($userId === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->select($db->quoteName('dolibarr_invoice_id'))
|
||||
->from($db->quoteName('#__mokodolijoomshop_orders'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('dolibarr_invoice_id') . ' = ' . $invoiceId);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ($db->loadResult() === null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get invoice documents
|
||||
$docs = $this->client->get('/documents', [
|
||||
'modulepart' => 'invoice',
|
||||
'id' => $invoiceId,
|
||||
]);
|
||||
|
||||
if (empty($docs))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find PDF
|
||||
foreach ($docs as $doc)
|
||||
{
|
||||
if (isset($doc['relativename']) && str_ends_with($doc['relativename'], '.pdf'))
|
||||
{
|
||||
return $doc;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time order status from Dolibarr.
|
||||
*
|
||||
* @param int $orderId Dolibarr order ID.
|
||||
*
|
||||
* @return string Status label.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getOrderStatus(int $orderId): string
|
||||
{
|
||||
$order = $this->client->get('/orders/' . $orderId);
|
||||
|
||||
if ($order === null)
|
||||
{
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$statusMap = [
|
||||
-1 => 'cancelled',
|
||||
0 => 'draft',
|
||||
1 => 'validated',
|
||||
2 => 'shipped',
|
||||
3 => 'delivered',
|
||||
];
|
||||
|
||||
$statusCode = (int) ($order['statut'] ?? $order['status'] ?? 0);
|
||||
|
||||
return $statusMap[$statusCode] ?? 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Single product model — fetches product detail from Dolibarr API.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class ProductModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single product by ID.
|
||||
*
|
||||
* @param int|null $id Product ID, or null to read from input.
|
||||
*
|
||||
* @return array|null Product data or null if not found.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItem(?int $id = null): ?array
|
||||
{
|
||||
if ($id === null)
|
||||
{
|
||||
$id = Factory::getApplication()->input->getInt('id', 0);
|
||||
}
|
||||
|
||||
if ($id <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->client->get('/products/' . $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock level for a product.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return float Total stock across all warehouses.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getStock(int $productId): float
|
||||
{
|
||||
$stockData = $this->client->get('/products/' . $productId . '/stock');
|
||||
|
||||
if ($stockData === null)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Sum stock across warehouses
|
||||
$total = 0.0;
|
||||
|
||||
if (isset($stockData['stock_warehouses']) && \is_array($stockData['stock_warehouses']))
|
||||
{
|
||||
foreach ($stockData['stock_warehouses'] as $warehouse)
|
||||
{
|
||||
$total += (float) ($warehouse['real'] ?? 0);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$total = (float) ($stockData['stock_reel'] ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product images from Dolibarr documents API.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
* @param string $ref Product reference for path building.
|
||||
*
|
||||
* @return array Array of image URLs.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getImages(int $productId, string $ref = ''): array
|
||||
{
|
||||
$docs = $this->client->get('/documents', [
|
||||
'modulepart' => 'product',
|
||||
'id' => $productId,
|
||||
]);
|
||||
|
||||
if ($docs === null || !\is_array($docs))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$images = [];
|
||||
|
||||
foreach ($docs as $doc)
|
||||
{
|
||||
if (isset($doc['relativename']) && preg_match('/\.(jpe?g|png|gif|webp)$/i', $doc['relativename']))
|
||||
{
|
||||
$images[] = [
|
||||
'name' => $doc['name'] ?? basename($doc['relativename']),
|
||||
'url' => $doc['fullname'] ?? $doc['relativename'],
|
||||
'encoded' => $doc['content'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related products from the same category.
|
||||
*
|
||||
* @param int $productId Current product ID.
|
||||
* @param int $limit Number of related products to return.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getRelated(int $productId, int $limit = 4): array
|
||||
{
|
||||
// Get categories for this product
|
||||
$categories = $this->client->get('/products/' . $productId . '/categories');
|
||||
|
||||
if (empty($categories))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$catId = (int) $categories[0]['id'];
|
||||
$products = $this->client->get('/categories/' . $catId . '/objects', [
|
||||
'type' => 'product',
|
||||
'limit' => $limit + 1,
|
||||
]);
|
||||
|
||||
if (empty($products))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Remove the current product from related
|
||||
return array_values(array_filter($products, function ($p) use ($productId) {
|
||||
return (int) $p['id'] !== $productId;
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Products list model — fetches products from Dolibarr API.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class ProductsModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* @var DolibarrClient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private DolibarrClient $client;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param array $config Configuration array.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->client = new DolibarrClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of products from Dolibarr.
|
||||
*
|
||||
* @return array Array of product objects.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$perPage = (int) $params->get('products_per_page', 12);
|
||||
$app = Factory::getApplication();
|
||||
$page = $app->input->getInt('page', 0);
|
||||
$categoryId = $app->input->getInt('category_id', 0);
|
||||
|
||||
$query = [
|
||||
'sortfield' => 't.ref',
|
||||
'sortorder' => 'ASC',
|
||||
'limit' => $perPage,
|
||||
'page' => $page,
|
||||
];
|
||||
|
||||
if ($categoryId > 0)
|
||||
{
|
||||
$query['category'] = $categoryId;
|
||||
}
|
||||
|
||||
$products = $this->client->get('/products', $query);
|
||||
|
||||
if ($products === null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter to only saleable products
|
||||
return array_values(array_filter($products, function ($product) {
|
||||
return !empty($product['status_buy']) || !empty($product['tosell']) || ((int) ($product['status'] ?? 0)) === 1;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product categories from Dolibarr.
|
||||
*
|
||||
* @return array Array of category objects.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getCategories(): array
|
||||
{
|
||||
$categories = $this->client->get('/categories', [
|
||||
'sortfield' => 't.label',
|
||||
'sortorder' => 'ASC',
|
||||
'type' => 'product',
|
||||
]);
|
||||
|
||||
return $categories ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total product count for pagination.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getTotal(): int
|
||||
{
|
||||
$products = $this->client->get('/products', [
|
||||
'limit' => 0,
|
||||
]);
|
||||
|
||||
if ($products === null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return \count($products);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of products per page.
|
||||
*
|
||||
* @return int
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getPerPage(): int
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
return (int) $params->get('products_per_page', 12);
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoDoliJoomShop\Administrator\Helper\DolibarrClient;
|
||||
|
||||
/**
|
||||
* Wishlist model — save for later functionality.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class WishlistModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get wishlist items for the current user.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
$query->select('*')
|
||||
->from($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->order($db->quoteName('created') . ' DESC');
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a product to the wishlist.
|
||||
*
|
||||
* @param int $productId Dolibarr product ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function addItem(int $productId): bool
|
||||
{
|
||||
$client = new DolibarrClient();
|
||||
$product = $client->get('/products/' . $productId);
|
||||
|
||||
if ($product === null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||
|
||||
// Check if already in wishlist
|
||||
$query = $db->getQuery(true);
|
||||
$query->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->where($db->quoteName('dolibarr_product_id') . ' = ' . $productId);
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
return true; // Already in wishlist
|
||||
}
|
||||
|
||||
$insert = $db->getQuery(true);
|
||||
$insert->insert($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->columns(['user_id', 'session_id', 'dolibarr_product_id', 'product_ref', 'product_label'])
|
||||
->values(implode(',', [
|
||||
$userId,
|
||||
$db->quote($sessionId),
|
||||
$productId,
|
||||
$db->quote($product['ref'] ?? ''),
|
||||
$db->quote($product['label'] ?? ''),
|
||||
]));
|
||||
|
||||
$db->setQuery($insert);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from the wishlist.
|
||||
*
|
||||
* @param int $wishlistItemId Wishlist row ID.
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function removeItem(int $wishlistItemId): bool
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$userId = (int) Factory::getApplication()->getIdentity()->id;
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->delete($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->where($db->quoteName('id') . ' = ' . $wishlistItemId);
|
||||
|
||||
if ($userId > 0)
|
||||
{
|
||||
$query->where($db->quoteName('user_id') . ' = ' . $userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$sessionId = Factory::getApplication()->getSession()->getId();
|
||||
$query->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->execute() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge guest wishlist into user account on login.
|
||||
*
|
||||
* @param string $sessionId Guest session ID.
|
||||
* @param int $userId User ID.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function mergeGuestWishlist(string $sessionId, int $userId): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
$query->update($db->quoteName('#__mokodolijoomshop_wishlist'))
|
||||
->set($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->where($db->quoteName('session_id') . ' = ' . $db->quote($sessionId))
|
||||
->where($db->quoteName('user_id') . ' = 0');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Application\SiteApplication;
|
||||
use Joomla\CMS\Component\Router\RouterBase;
|
||||
use Joomla\CMS\Menu\AbstractMenu;
|
||||
|
||||
/**
|
||||
* SEF URL router for com_mokodolijoomshop.
|
||||
*
|
||||
* URL patterns:
|
||||
* /shop → products view
|
||||
* /shop/cart → cart view
|
||||
* /shop/checkout → checkout view
|
||||
* /shop/my-orders → orders view
|
||||
* /shop/category/{id} → category view
|
||||
* /shop/product/{id} → product view
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Router extends RouterBase
|
||||
{
|
||||
/**
|
||||
* Build SEF URL segments from query parameters.
|
||||
*
|
||||
* @param array &$query Query parameters.
|
||||
*
|
||||
* @return array URL segments.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function build(&$query): array
|
||||
{
|
||||
$segments = [];
|
||||
$view = $query['view'] ?? 'products';
|
||||
|
||||
unset($query['view']);
|
||||
|
||||
switch ($view)
|
||||
{
|
||||
case 'products':
|
||||
// No extra segment — the menu item handles it
|
||||
break;
|
||||
|
||||
case 'product':
|
||||
$segments[] = 'product';
|
||||
|
||||
if (isset($query['id']))
|
||||
{
|
||||
$segments[] = $query['id'];
|
||||
unset($query['id']);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'category':
|
||||
$segments[] = 'category';
|
||||
|
||||
if (isset($query['id']))
|
||||
{
|
||||
$segments[] = $query['id'];
|
||||
unset($query['id']);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'cart':
|
||||
$segments[] = 'cart';
|
||||
break;
|
||||
|
||||
case 'checkout':
|
||||
$segments[] = 'checkout';
|
||||
break;
|
||||
|
||||
case 'orders':
|
||||
$segments[] = 'my-orders';
|
||||
|
||||
if (isset($query['order_id']))
|
||||
{
|
||||
$segments[] = $query['order_id'];
|
||||
unset($query['order_id']);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle task-based URLs (cart.add, cart.remove, etc.)
|
||||
if (isset($query['task']))
|
||||
{
|
||||
// Keep task in query for controller routing
|
||||
}
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SEF URL segments into query parameters.
|
||||
*
|
||||
* @param array &$segments URL segments.
|
||||
*
|
||||
* @return array Query parameters.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function parse(&$segments): array
|
||||
{
|
||||
$vars = [];
|
||||
$count = \count($segments);
|
||||
|
||||
if ($count === 0)
|
||||
{
|
||||
$vars['view'] = 'products';
|
||||
|
||||
return $vars;
|
||||
}
|
||||
|
||||
$first = $segments[0];
|
||||
|
||||
switch ($first)
|
||||
{
|
||||
case 'product':
|
||||
$vars['view'] = 'product';
|
||||
|
||||
if ($count > 1)
|
||||
{
|
||||
$vars['id'] = (int) $segments[1];
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'category':
|
||||
$vars['view'] = 'category';
|
||||
|
||||
if ($count > 1)
|
||||
{
|
||||
$vars['id'] = (int) $segments[1];
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'cart':
|
||||
$vars['view'] = 'cart';
|
||||
break;
|
||||
|
||||
case 'checkout':
|
||||
$vars['view'] = 'checkout';
|
||||
break;
|
||||
|
||||
case 'my-orders':
|
||||
$vars['view'] = 'orders';
|
||||
|
||||
if ($count > 1)
|
||||
{
|
||||
$vars['order_id'] = (int) $segments[1];
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
// Try to resolve as product ID or fall back
|
||||
if (is_numeric($first))
|
||||
{
|
||||
$vars['view'] = 'product';
|
||||
$vars['id'] = (int) $first;
|
||||
}
|
||||
else
|
||||
{
|
||||
$vars['view'] = 'products';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
|
||||
return $vars;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Cart;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Shopping cart view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Cart items.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $items = [];
|
||||
|
||||
/**
|
||||
* @var array Cart totals (subtotal, tax, total, count).
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $totals = [];
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* Display the cart.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$this->items = $model->getItems();
|
||||
$this->totals = $model->getTotals();
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Category;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Category landing page view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array|null Current category data.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected ?array $category = null;
|
||||
|
||||
/**
|
||||
* @var array Products in this category.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $items = [];
|
||||
|
||||
/**
|
||||
* @var array Category tree for sidebar.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $categoryTree = [];
|
||||
|
||||
/**
|
||||
* @var array Breadcrumbs path.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $breadcrumbs = [];
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* @var int Current page.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $page = 0;
|
||||
|
||||
/**
|
||||
* @var int Per page count.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $perPage = 12;
|
||||
|
||||
/**
|
||||
* Display the category page.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$app = Factory::getApplication();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$categoryId = $app->input->getInt('id', 0);
|
||||
|
||||
$this->category = $model->getCategory($categoryId);
|
||||
$this->categoryTree = $model->getCategoryTree();
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
$this->page = $app->input->getInt('page', 0);
|
||||
$this->perPage = (int) $params->get('products_per_page', 12);
|
||||
|
||||
if ($this->category !== null)
|
||||
{
|
||||
$this->items = $model->getCategoryProducts($categoryId);
|
||||
$this->breadcrumbs = $model->getBreadcrumbs($categoryId);
|
||||
|
||||
$app->getDocument()->setTitle(htmlspecialchars($this->category['label'] ?? 'Category'));
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Checkout;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Checkout view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Cart items.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $cartItems = [];
|
||||
|
||||
/**
|
||||
* @var array Cart totals.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $totals = [];
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* @var string Checkout mode.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $checkoutMode = 'both';
|
||||
|
||||
/**
|
||||
* @var \Joomla\CMS\User\User|null Current user.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected $user = null;
|
||||
|
||||
/**
|
||||
* @var array|null Order result for confirmation page.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected ?array $orderResult = null;
|
||||
|
||||
/**
|
||||
* Display the checkout view.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$layout = $app->input->getString('layout', 'default');
|
||||
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
$this->checkoutMode = $params->get('checkout_mode', 'both');
|
||||
$this->user = $app->getIdentity();
|
||||
|
||||
if ($layout === 'confirmation')
|
||||
{
|
||||
$this->orderResult = $app->getSession()->get('mokodolijoomshop.order_result');
|
||||
$app->getSession()->clear('mokodolijoomshop.order_result');
|
||||
}
|
||||
else
|
||||
{
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\Model\CartModel $cartModel */
|
||||
$cartModel = $this->getModel('Cart');
|
||||
$this->cartItems = $cartModel->getItems();
|
||||
$this->totals = $cartModel->getTotals();
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Orders;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Order history view (My Orders).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array User's orders.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $orders = [];
|
||||
|
||||
/**
|
||||
* @var array|null Single order detail.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected ?array $orderDetail = null;
|
||||
|
||||
/**
|
||||
* @var string Currency.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* @var bool Whether user is logged in.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected bool $isGuest = true;
|
||||
|
||||
/**
|
||||
* Display the orders view.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
$model = $this->getModel();
|
||||
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
$this->isGuest = empty($app->getIdentity()->id);
|
||||
|
||||
if (!$this->isGuest)
|
||||
{
|
||||
$orderId = $app->input->getInt('order_id', 0);
|
||||
|
||||
if ($orderId > 0)
|
||||
{
|
||||
$this->orderDetail = $model->getOrderDetail($orderId);
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->orders = $model->getUserOrders();
|
||||
}
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Product;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
/**
|
||||
* Single product detail view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array|null Product data.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected ?array $item = null;
|
||||
|
||||
/**
|
||||
* @var float Stock quantity.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected float $stock = 0.0;
|
||||
|
||||
/**
|
||||
* @var array Product images.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $images = [];
|
||||
|
||||
/**
|
||||
* @var array Related products.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $related = [];
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* Display the product detail page.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$this->item = $model->getItem();
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
|
||||
if ($this->item !== null)
|
||||
{
|
||||
$productId = (int) $this->item['id'];
|
||||
$this->stock = $model->getStock($productId);
|
||||
$this->images = $model->getImages($productId, $this->item['ref'] ?? '');
|
||||
$this->related = $model->getRelated($productId);
|
||||
|
||||
// Set page title
|
||||
$app = Factory::getApplication();
|
||||
$app->getDocument()->setTitle(htmlspecialchars($this->item['label'] ?? 'Product'));
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoDoliJoomShop\Site\View\Products;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
|
||||
/**
|
||||
* Product catalog listing view.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Product items from Dolibarr.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $items = [];
|
||||
|
||||
/**
|
||||
* @var array Product categories.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected array $categories = [];
|
||||
|
||||
/**
|
||||
* @var int Current page number.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $page = 0;
|
||||
|
||||
/**
|
||||
* @var int Active category filter.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $categoryId = 0;
|
||||
|
||||
/**
|
||||
* @var string Currency code.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected string $currency = 'USD';
|
||||
|
||||
/**
|
||||
* @var int Products per page.
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected int $perPage = 12;
|
||||
|
||||
/**
|
||||
* Display the products catalog.
|
||||
*
|
||||
* @param string $tpl Template name.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$app = Factory::getApplication();
|
||||
$params = ComponentHelper::getParams('com_mokodolijoomshop');
|
||||
|
||||
$this->items = $model->getItems();
|
||||
$this->categories = $model->getCategories();
|
||||
$this->page = $app->input->getInt('page', 0);
|
||||
$this->categoryId = $app->input->getInt('category_id', 0);
|
||||
$this->currency = $params->get('currency', 'USD');
|
||||
$this->perPage = $model->getPerPage();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Cart\HtmlView $this */
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-cart">
|
||||
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART'); ?></h2>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'); ?>
|
||||
</div>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_REF'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE'); ?></th>
|
||||
<th class="text-center"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $item) : ?>
|
||||
<?php $lineTotal = (float) $item['unit_price'] * (int) $item['quantity']; ?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . (int) $item['dolibarr_product_id']); ?>">
|
||||
<?php echo htmlspecialchars($item['product_label']); ?>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-muted"><?php echo htmlspecialchars($item['product_ref']); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) $item['unit_price'], 2); ?> <?php echo $currency; ?></td>
|
||||
<td class="text-center">
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.update'); ?>" method="post" class="d-inline">
|
||||
<input type="hidden" name="cart_item_id" value="<?php echo (int) $item['id']; ?>" />
|
||||
<input type="number" name="quantity" value="<?php echo (int) $item['quantity']; ?>" min="1" max="999" class="form-control form-control-sm d-inline-block" style="width: 70px;" onchange="this.form.submit();" />
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-end fw-bold"><?php echo number_format($lineTotal, 2); ?> <?php echo $currency; ?></td>
|
||||
<td>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.remove'); ?>" method="post" class="d-inline">
|
||||
<input type="hidden" name="cart_item_id" value="<?php echo (int) $item['id']; ?>" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="<?php echo Text::_('JACTION_DELETE'); ?>">
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
</button>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-end">
|
||||
<div class="col-md-4">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></td>
|
||||
<td class="text-end"><?php echo number_format($this->totals['subtotal'], 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<?php if ($this->totals['tax'] > 0) : ?>
|
||||
<tr>
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TAX'); ?></td>
|
||||
<td class="text-end"><?php echo number_format($this->totals['tax'], 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr class="fw-bold fs-5">
|
||||
<td><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></td>
|
||||
<td class="text-end"><?php echo number_format($this->totals['total'], 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=checkout'); ?>" class="btn btn-primary btn-lg">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PROCEED_CHECKOUT'); ?>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-outline-secondary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_CART">
|
||||
<message>COM_MOKODOLIJOOMSHOP_CART_DESC</message>
|
||||
</layout>
|
||||
</metadata>
|
||||
@@ -1,159 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Category\HtmlView $this */
|
||||
|
||||
if ($this->category === null) :
|
||||
?>
|
||||
<div class="alert alert-warning"><?php echo Text::_('JGLOBAL_RESOURCE_NOT_FOUND'); ?></div>
|
||||
<?php return;
|
||||
endif;
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
$catLabel = htmlspecialchars($this->category['label'] ?? '');
|
||||
$catDesc = $this->category['description'] ?? '';
|
||||
$categoryId = (int) $this->category['id'];
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-category">
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php foreach ($this->breadcrumbs as $i => $crumb) : ?>
|
||||
<?php if ($i === \count($this->breadcrumbs) - 1) : ?>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?php echo htmlspecialchars($crumb['label']); ?></li>
|
||||
<?php else : ?>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=category&id=' . (int) $crumb['id']); ?>">
|
||||
<?php echo htmlspecialchars($crumb['label']); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<!-- Category sidebar -->
|
||||
<div class="col-md-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CATEGORIES'); ?></h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php echo mokoshop_render_category_tree($this->categoryTree, $categoryId); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products grid -->
|
||||
<div class="col-md-9">
|
||||
<h2><?php echo $catLabel; ?></h2>
|
||||
<?php if ($catDesc) : ?>
|
||||
<p class="text-muted"><?php echo $catDesc; ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_PRODUCTS'); ?></div>
|
||||
<?php else : ?>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
<?php foreach ($this->items as $product) : ?>
|
||||
<?php
|
||||
$productId = (int) $product['id'];
|
||||
$label = htmlspecialchars($product['label'] ?? $product['ref'] ?? '');
|
||||
$price = (float) ($product['price_ttc'] ?? $product['price'] ?? 0);
|
||||
$detailLink = Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . $productId);
|
||||
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||
$inStock = $stockReel > 0;
|
||||
?>
|
||||
<div class="col">
|
||||
<div class="card h-100 product-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="<?php echo $detailLink; ?>"><?php echo $label; ?></a>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold"><?php echo number_format($price, 2); ?> <?php echo $currency; ?></span>
|
||||
<?php if ($inStock) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId); ?>" class="btn btn-sm btn-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<span class="badge bg-secondary"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php $baseUrl = 'index.php?option=com_mokodolijoomshop&view=category&id=' . $categoryId; ?>
|
||||
<nav class="mt-4" aria-label="<?php echo Text::_('JLIB_HTML_PAGINATION'); ?>">
|
||||
<ul class="pagination justify-content-center">
|
||||
<?php if ($this->page > 0) : ?>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page - 1)); ?>">« <?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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_CATEGORY">
|
||||
<message>COM_MOKODOLIJOOMSHOP_CATEGORY_DESC</message>
|
||||
</layout>
|
||||
<fields name="params">
|
||||
<fieldset name="request" label="COM_MOKODOLIJOOMSHOP_CATEGORY_OPTIONS">
|
||||
<field
|
||||
name="id"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_CATEGORY_ID"
|
||||
description="COM_MOKODOLIJOOMSHOP_CATEGORY_ID_DESC"
|
||||
required="true"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</metadata>
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Checkout\HtmlView $this */
|
||||
|
||||
$order = $this->orderResult;
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-checkout-confirmation">
|
||||
<?php if ($order === null) : ?>
|
||||
<div class="alert alert-warning">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDER_DATA'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="text-center py-5">
|
||||
<span class="icon-check-circle text-success" style="font-size: 4rem;" aria-hidden="true"></span>
|
||||
<h2 class="mt-3"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_PLACED'); ?></h2>
|
||||
|
||||
<div class="card mx-auto mt-4" style="max-width: 400px;">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-5"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></dt>
|
||||
<dd class="col-sm-7 fw-bold"><?php echo htmlspecialchars($order['order_ref'] ?? ''); ?></dd>
|
||||
|
||||
<?php if (!empty($order['invoice_ref'])) : ?>
|
||||
<dt class="col-sm-5"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></dt>
|
||||
<dd class="col-sm-7"><?php echo htmlspecialchars($order['invoice_ref']); ?></dd>
|
||||
<?php endif; ?>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary mt-4">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,129 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Checkout\HtmlView $this */
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
$isGuest = empty($this->user->id);
|
||||
$userName = $isGuest ? '' : htmlspecialchars($this->user->name);
|
||||
$userEmail = $isGuest ? '' : htmlspecialchars($this->user->email);
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-checkout">
|
||||
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT'); ?></h2>
|
||||
|
||||
<?php if (empty($this->cartItems)) : ?>
|
||||
<div class="alert alert-warning"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_CART_EMPTY'); ?></div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($this->checkoutMode === 'registered' && $isGuest) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CHECKOUT_LOGIN_REQUIRED'); ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_users&view=login'); ?>" class="btn btn-primary ms-2">
|
||||
<?php echo Text::_('JLOGIN'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokodolijoomshop&task=checkout.process'); ?>" method="post" id="checkoutForm">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_DETAILS'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="billing_name" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_NAME'); ?> *</label>
|
||||
<input type="text" id="billing_name" name="billing_name" class="form-control" required value="<?php echo $userName; ?>" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="billing_email" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_EMAIL'); ?> *</label>
|
||||
<input type="email" id="billing_email" name="billing_email" class="form-control" required value="<?php echo $userEmail; ?>" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="billing_address" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_ADDRESS'); ?></label>
|
||||
<textarea id="billing_address" name="billing_address" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="billing_town" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_TOWN'); ?></label>
|
||||
<input type="text" id="billing_town" name="billing_town" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="billing_zip" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_ZIP'); ?></label>
|
||||
<input type="text" id="billing_zip" name="billing_zip" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="billing_phone" class="form-label"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_BILLING_PHONE'); ?></label>
|
||||
<input type="tel" id="billing_phone" name="billing_phone" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_NOTES'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<textarea name="order_notes" class="form-control" rows="3" placeholder="<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_NOTES_PLACEHOLDER'); ?>"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<span class="icon-cart" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PLACE_ORDER'); ?>
|
||||
</button>
|
||||
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_SUMMARY'); ?></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php foreach ($this->cartItems as $item) : ?>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>
|
||||
<?php echo htmlspecialchars($item['product_label']); ?>
|
||||
<small class="text-muted">× <?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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_CHECKOUT">
|
||||
<message>COM_MOKODOLIJOOMSHOP_CHECKOUT_DESC</message>
|
||||
</layout>
|
||||
</metadata>
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Orders\HtmlView $this */
|
||||
|
||||
$currency = htmlspecialchars($this->currency);
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-orders">
|
||||
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_MY_ORDERS'); ?></h2>
|
||||
|
||||
<?php if ($this->isGuest) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDERS_LOGIN_REQUIRED'); ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_users&view=login'); ?>" class="btn btn-primary ms-2">
|
||||
<?php echo Text::_('JLOGIN'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($this->orderDetail !== null) : ?>
|
||||
<?php // Order detail view ?>
|
||||
<?php
|
||||
$order = $this->orderDetail;
|
||||
$ref = htmlspecialchars($order['ref'] ?? '');
|
||||
?>
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders'); ?>">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_MY_ORDERS'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active"><?php echo $ref; ?></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<h4 class="mb-0"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?>: <?php echo $ref; ?></h4>
|
||||
<span class="badge bg-info"><?php echo htmlspecialchars($order['statut_label'] ?? $order['status'] ?? ''); ?></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_LABEL'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE'); ?></th>
|
||||
<th class="text-center"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_SUBTOTAL'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($order['lines'] ?? [] as $line) : ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($line['desc'] ?? $line['product_label'] ?? ''); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) ($line['subprice'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||
<td class="text-center"><?php echo (int) ($line['qty'] ?? 0); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) ($line['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="fw-bold">
|
||||
<td colspan="3" class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) ($order['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php elseif (empty($this->orders)) : ?>
|
||||
<div class="alert alert-info"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_ORDERS'); ?></div>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>" class="btn btn-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_CONTINUE_SHOPPING'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_DATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_REF'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_INVOICE_REF'); ?></th>
|
||||
<th class="text-end"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_TOTAL'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKODOLIJOOMSHOP_ORDER_STATUS'); ?></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->orders as $order) : ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($order['created'] ?? ''); ?></td>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders&order_id=' . (int) $order['dolibarr_order_id']); ?>">
|
||||
<?php echo htmlspecialchars($order['order_ref']); ?>
|
||||
</a>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($order['invoice_ref'] ?? ''); ?></td>
|
||||
<td class="text-end"><?php echo number_format((float) ($order['total_ttc'] ?? 0), 2); ?> <?php echo $currency; ?></td>
|
||||
<td>
|
||||
<span class="badge bg-secondary"><?php echo htmlspecialchars($order['status'] ?? ''); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=orders&order_id=' . (int) $order['dolibarr_order_id']); ?>" class="btn btn-sm btn-outline-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_VIEW_DETAIL'); ?>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_MY_ORDERS">
|
||||
<message>COM_MOKODOLIJOOMSHOP_MY_ORDERS_DESC</message>
|
||||
</layout>
|
||||
</metadata>
|
||||
@@ -1,159 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Product\HtmlView $this */
|
||||
|
||||
if ($this->item === null) :
|
||||
?>
|
||||
<div class="alert alert-warning"><?php echo Text::_('JGLOBAL_RESOURCE_NOT_FOUND'); ?></div>
|
||||
<?php return;
|
||||
endif;
|
||||
|
||||
$product = $this->item;
|
||||
$ref = htmlspecialchars($product['ref'] ?? '');
|
||||
$label = htmlspecialchars($product['label'] ?? $ref);
|
||||
$description = $product['description'] ?? '';
|
||||
$priceHT = (float) ($product['price'] ?? 0);
|
||||
$priceTTC = (float) ($product['price_ttc'] ?? $priceHT);
|
||||
$barcode = htmlspecialchars($product['barcode'] ?? '');
|
||||
$inStock = $this->stock > 0;
|
||||
$productId = (int) $product['id'];
|
||||
$addCartLink = Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId);
|
||||
?>
|
||||
|
||||
<?php // Schema.org Product JSON-LD ?>
|
||||
<script type="application/ld+json">
|
||||
<?php echo json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Product',
|
||||
'name' => $label,
|
||||
'description' => strip_tags($description),
|
||||
'sku' => $ref,
|
||||
'offers' => [
|
||||
'@type' => 'Offer',
|
||||
'price' => number_format($priceTTC, 2, '.', ''),
|
||||
'priceCurrency' => $this->currency,
|
||||
'availability' => $inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); ?>
|
||||
</script>
|
||||
|
||||
<div class="com-mokodolijoomshop-product">
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?php echo $label; ?></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<?php if (!empty($this->images)) : ?>
|
||||
<div class="product-gallery mb-3">
|
||||
<?php foreach ($this->images as $i => $image) : ?>
|
||||
<img
|
||||
src="<?php echo htmlspecialchars($image['url']); ?>"
|
||||
alt="<?php echo $label; ?>"
|
||||
class="img-fluid rounded <?php echo $i > 0 ? 'mt-2' : ''; ?>"
|
||||
loading="<?php echo $i === 0 ? 'eager' : 'lazy'; ?>"
|
||||
/>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="height: 300px;">
|
||||
<span class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_IMAGE'); ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<h1><?php echo $label; ?></h1>
|
||||
<p class="text-muted"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCT_REF'); ?>: <?php echo $ref; ?></p>
|
||||
|
||||
<?php if ($barcode) : ?>
|
||||
<p class="small text-muted">Barcode: <?php echo $barcode; ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<span class="fs-3 fw-bold">
|
||||
<?php echo number_format($priceTTC, 2); ?> <?php echo htmlspecialchars($this->currency); ?>
|
||||
</span>
|
||||
<?php if ($priceHT !== $priceTTC) : ?>
|
||||
<br>
|
||||
<span class="text-muted small">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRICE_HT'); ?>: <?php echo number_format($priceHT, 2); ?> <?php echo htmlspecialchars($this->currency); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<?php if ($inStock) : ?>
|
||||
<span class="badge bg-success"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_IN_STOCK'); ?></span>
|
||||
<span class="text-muted small ms-2">(<?php echo (int) $this->stock; ?> <?php echo Text::_('COM_MOKODOLIJOOMSHOP_AVAILABLE'); ?>)</span>
|
||||
<?php else : ?>
|
||||
<span class="badge bg-danger"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($inStock) : ?>
|
||||
<form action="<?php echo $addCartLink; ?>" method="post" class="mb-4">
|
||||
<div class="input-group" style="max-width: 250px;">
|
||||
<label for="quantity" class="visually-hidden"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_QUANTITY'); ?></label>
|
||||
<input type="number" id="quantity" name="quantity" value="1" min="1" max="<?php echo (int) $this->stock; ?>" class="form-control" />
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-cart" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($description) : ?>
|
||||
<div class="product-description mt-4">
|
||||
<h4><?php echo Text::_('COM_MOKODOLIJOOMSHOP_DESCRIPTION'); ?></h4>
|
||||
<div><?php echo $description; ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($this->related)) : ?>
|
||||
<div class="mt-5">
|
||||
<h3><?php echo Text::_('COM_MOKODOLIJOOMSHOP_RELATED_PRODUCTS'); ?></h3>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-3">
|
||||
<?php foreach (array_slice($this->related, 0, 4) as $rel) : ?>
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . (int) $rel['id']); ?>">
|
||||
<?php echo htmlspecialchars($rel['label'] ?? $rel['ref'] ?? ''); ?>
|
||||
</a>
|
||||
</h6>
|
||||
<span class="fw-bold">
|
||||
<?php echo number_format((float) ($rel['price_ttc'] ?? $rel['price'] ?? 0), 2); ?>
|
||||
<?php echo htmlspecialchars($this->currency); ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL">
|
||||
<message>COM_MOKODOLIJOOMSHOP_PRODUCT_DETAIL_DESC</message>
|
||||
</layout>
|
||||
<fields name="params">
|
||||
<fieldset name="request" label="COM_MOKODOLIJOOMSHOP_PRODUCT_OPTIONS">
|
||||
<field
|
||||
name="id"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_PRODUCT_ID"
|
||||
description="COM_MOKODOLIJOOMSHOP_PRODUCT_ID_DESC"
|
||||
required="true"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</metadata>
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoDoliJoomShop
|
||||
* @subpackage com_mokodolijoomshop
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/** @var \Moko\Component\MokoDoliJoomShop\Site\View\Products\HtmlView $this */
|
||||
?>
|
||||
<div class="com-mokodolijoomshop-products">
|
||||
<h2><?php echo Text::_('COM_MOKODOLIJOOMSHOP_PRODUCTS'); ?></h2>
|
||||
|
||||
<?php if (!empty($this->categories)) : ?>
|
||||
<nav class="shop-categories mb-4">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products'); ?>"
|
||||
class="btn btn-sm <?php echo $this->categoryId === 0 ? 'btn-primary' : 'btn-outline-secondary'; ?> me-1 mb-1">
|
||||
<?php echo Text::_('JALL'); ?>
|
||||
</a>
|
||||
<?php foreach ($this->categories as $cat) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokodolijoomshop&view=products&category_id=' . (int) $cat['id']); ?>"
|
||||
class="btn btn-sm <?php echo $this->categoryId === (int) $cat['id'] ? 'btn-primary' : 'btn-outline-secondary'; ?> me-1 mb-1">
|
||||
<?php echo htmlspecialchars($cat['label']); ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_NO_PRODUCTS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
|
||||
<?php foreach ($this->items as $product) : ?>
|
||||
<?php
|
||||
$productId = (int) $product['id'];
|
||||
$ref = htmlspecialchars($product['ref'] ?? '');
|
||||
$label = htmlspecialchars($product['label'] ?? $ref);
|
||||
$price = (float) ($product['price_ttc'] ?? $product['price'] ?? 0);
|
||||
$description = htmlspecialchars(strip_tags($product['description'] ?? ''));
|
||||
$detailLink = Route::_('index.php?option=com_mokodolijoomshop&view=product&id=' . $productId);
|
||||
$addCartLink = Route::_('index.php?option=com_mokodolijoomshop&task=cart.add&product_id=' . $productId);
|
||||
$stockReel = (float) ($product['stock_reel'] ?? 0);
|
||||
$inStock = $stockReel > 0;
|
||||
?>
|
||||
<div class="col">
|
||||
<div class="card h-100 product-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="<?php echo $detailLink; ?>"><?php echo $label; ?></a>
|
||||
</h5>
|
||||
<p class="card-text text-muted small"><?php echo $ref; ?></p>
|
||||
<?php if ($description) : ?>
|
||||
<p class="card-text"><?php echo mb_strimwidth($description, 0, 120, '…'); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold fs-5">
|
||||
<?php echo number_format($price, 2); ?>
|
||||
<?php echo htmlspecialchars($this->currency); ?>
|
||||
</span>
|
||||
<?php if ($inStock) : ?>
|
||||
<a href="<?php echo $addCartLink; ?>" class="btn btn-sm btn-primary">
|
||||
<?php echo Text::_('COM_MOKODOLIJOOMSHOP_ADD_TO_CART'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<span class="badge bg-secondary"><?php echo Text::_('COM_MOKODOLIJOOMSHOP_OUT_OF_STOCK'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php // Pagination ?>
|
||||
<?php $baseUrl = 'index.php?option=com_mokodolijoomshop&view=products'
|
||||
. ($this->categoryId ? '&category_id=' . $this->categoryId : ''); ?>
|
||||
<nav class="mt-4" aria-label="<?php echo Text::_('JLIB_HTML_PAGINATION'); ?>">
|
||||
<ul class="pagination justify-content-center">
|
||||
<?php if ($this->page > 0) : ?>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="<?php echo Route::_($baseUrl . '&page=' . ($this->page - 1)); ?>">
|
||||
« <?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>
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<metadata>
|
||||
<layout title="COM_MOKODOLIJOOMSHOP_PRODUCTS">
|
||||
<message>COM_MOKODOLIJOOMSHOP_PRODUCTS_DESC</message>
|
||||
</layout>
|
||||
<fields name="params">
|
||||
<fieldset name="basic" label="COM_MOKODOLIJOOMSHOP_FIELDSET_SHOP">
|
||||
<field
|
||||
name="products_per_page"
|
||||
type="number"
|
||||
label="COM_MOKODOLIJOOMSHOP_FIELD_PRODUCTS_PER_PAGE"
|
||||
default=""
|
||||
min="1"
|
||||
max="100"
|
||||
hint="COM_MOKODOLIJOOMSHOP_USE_GLOBAL"
|
||||
/>
|
||||
<field
|
||||
name="sort_order"
|
||||
type="list"
|
||||
label="COM_MOKODOLIJOOMSHOP_SORT_BY"
|
||||
default=""
|
||||
>
|
||||
<option value="">COM_MOKODOLIJOOMSHOP_USE_GLOBAL</option>
|
||||
<option value="ref_asc">COM_MOKODOLIJOOMSHOP_SORT_REF_ASC</option>
|
||||
<option value="price_asc">COM_MOKODOLIJOOMSHOP_SORT_PRICE_ASC</option>
|
||||
<option value="price_desc">COM_MOKODOLIJOOMSHOP_SORT_PRICE_DESC</option>
|
||||
<option value="newest">COM_MOKODOLIJOOMSHOP_SORT_NEWEST</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</metadata>
|
||||
Reference in New Issue
Block a user